wiki/b-reference/ui-nyla/formgenerator.md
2026-06-02 09:42:12 +02:00

480 lines
21 KiB
Markdown

<!-- status: canonical -->
<!-- lastReviewed: 2026-05-03 -->
<!-- verifiedAgainst: ui-nyla (FormGeneratorTable.tsx, FormGeneratorControls.tsx, FormGeneratorTree.tsx) + gateway routeHelpers.py -->
# FormGenerator -- Referenz
## Uebersicht
Der FormGenerator besteht aus mehreren Komponenten:
| Komponente | Datei | Zweck |
|------------|-------|-------|
| `FormGeneratorTable` | `components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx` | Generische Datentabelle mit Pagination, Sortierung, Filterung, Suche, Selektion, Bulk-Actions |
| `FormGeneratorControls` | `components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx` | Toolbar: Suche, Pagination, Batch-Action-Buttons, "Select All Filtered"-Banner |
| `FormGeneratorForm` | `components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx` | Dynamische Formulare aus Backend-Attribut-Definitionen |
| `FormGeneratorList` | `components/FormGenerator/FormGeneratorList/FormGeneratorList.tsx` | Listen-Darstellung |
| `FormGeneratorReport` | `components/FormGenerator/FormGeneratorReport/FormGeneratorReport.tsx` | Berichte / Report-Layouts (nicht: es gibt kein separates `FormGeneratorChart`) |
| `FormGeneratorTree` | `components/FormGenerator/FormGeneratorTree/FormGeneratorTree.tsx` | Generische Baumkomponente mit Provider-Pattern, Multiselect, DnD, Inline-Editing, Scope/Neutralize, Batch-Actions |
Diese Referenz dokumentiert **FormGeneratorTable** (inkl. Backend-API-Pattern) und **FormGeneratorTree**.
---
## Page Layout Chain (Pflicht)
`FormGeneratorTable` rendert intern mit `flex:1; min-height:0; overflow:hidden`. Damit die Tabelle die volle Seitenbreite/-hoehe ausnuetzt und nicht abgeschnitten wird, **muss** die Eltern-Hierarchie eine **bounded height chain** liefern. Andernfalls passiert eines davon:
- Tabelle ist 0 px hoch (kollabiert) -- nur Toolbar sichtbar.
- Tabelle waechst ueber den Viewport hinaus, horizontaler Scroll fehlt -- letzte Spalten werden abgeschnitten.
- Page-Container hat `max-width` -> Tabelle ist auf z.B. 800 px begrenzt.
### Korrektes Pattern (verwendet PromptsPage, TrusteePositionsView, TrusteeDataTablesView)
```tsx
import adminStyles from '../../admin/Admin.module.css';
export const MyPage: React.FC = () => (
<div className={`${adminStyles.adminPage} ${adminStyles.adminPageFill}`}>
<div className={adminStyles.pageHeader}>...</div>
<div className={adminStyles.tableContainer}>
<FormGeneratorTable {...props} />
</div>
</div>
);
```
Die drei CSS-Klassen aus `ui-nyla/src/pages/admin/Admin.module.css`:
| Klasse | Wirkung |
|--------|---------|
| `.adminPage` | `display:flex; flex-direction:column; width:100%; box-sizing:border-box; flex:0 0 auto` (Default fuer Standard-Seiten ohne Tabelle) |
| `.adminPageFill` | `flex:1 1 auto; min-height:0; overflow:hidden` -- aktiviert die bounded height chain |
| `.tableContainer` | `flex:1; min-height:0; overflow:hidden; display:flex; flex-direction:column` -- direktes Eltern-Element der Tabelle |
`FeatureView`/`MainLayout` liefern bereits `height:100%` mit `min-height:0` -- Page-Komponenten brauchen also nur `adminPage adminPageFill` und einen `tableContainer` direkt um die `FormGeneratorTable`.
### Bei Tabs / Wrapper-Komponenten
Wenn die Tabelle in einem Tab-Wrapper (z.B. `TrusteeDataTab`) gemountet wird, muss **jeder** Wrapper die Flex-Chain weiterreichen. Pattern:
```tsx
const _rootStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0,
width: '100%',
};
const _tableWrapStyle: React.CSSProperties = {
flex: 1,
minHeight: 0,
display: 'flex',
flexDirection: 'column',
width: '100%',
};
```
Eine Toolbar oberhalb der Tabelle bekommt `flexShrink: 0`, der Tabellenwrapper `flex: 1; minHeight: 0`.
### Anti-Patterns (verursachen das "Tabelle abgeschnitten"-Bug)
- Outer-Wrapper mit `max-width: 800px` (z.B. `.expenseImportSection` aus `TrusteeViews.module.css`) -- begrenzt die Breite.
- Outer `<div>` ohne `flex:1; min-height:0` -- Tabelle kollabiert auf 0 px Hoehe.
- `.adminPage` ohne `.adminPageFill` -- Default ist `flex:0 0 auto`, das innere `.tableContainer flex:1` hat keinen Platz.
- Padding auf einem Zwischen-Wrapper -- bricht das Box-Sizing der Chain.
---
## FormGeneratorTable -- Props
### Datenanbindung
| Prop | Typ | Beschreibung |
|------|-----|-------------|
| `data` | `T[]` | Anzuzeigende Datensaetze (aktuelle Seite) |
| `columns` | `ColumnConfig[]` | Spaltendefinitionen (key, label, type, sortable, filterable, searchable, filterOptions, width) |
| `apiEndpoint` | `string?` | Backend-Endpunkt fuer automatische Filter-/ID-Abfragen |
| `hookData` | `{ refetch, pagination, fetchFilterValues? }` | Daten-Hook mit Refetch-Callback und Pagination-Metadata |
| `supportsBackendPagination` | `boolean` | Aktiviert serverseitige Pagination, Sortierung und Filterung |
### Interaktion
| Prop | Typ | Beschreibung |
|------|-----|-------------|
| `selectable` | `boolean` | Aktiviert Zeilen-Selektion (Checkboxen) |
| `onSelectionChange` | `(selectedIds: Set<string>) => void` | Callback bei Aenderung der Selektion |
| `idField` | `string` | Feld fuer eindeutige ID (Default: `"id"`) |
| `isRowSelectable` | `(row: T) => boolean` | Bestimmt, ob eine Zeile selektierbar ist |
| `batchActions` | `BatchAction[]` | Bulk-Aktionen fuer selektierte Zeilen |
| `actionButtons` | `ActionButton[]` | Zeilen-Aktionen (Edit, Delete etc.) |
### BatchAction Interface
```typescript
interface BatchAction<T> {
label: string;
icon?: React.ReactNode;
loading?: boolean;
onClick: (selectedRows: T[]) => void | Promise<void>;
isApplicable?: (row: T) => boolean;
}
```
`isApplicable` ermoeglicht pro Action zu bestimmen, welche der selektierten Zeilen fuer diese Aktion qualifizieren. Der Button zeigt dann `(applicableCount/totalSelected)` und ist `disabled` wenn `applicableCount === 0`.
---
## Unified Filter API
### Prinzip
Alle Daten-Endpunkte unterstuetzen drei Modi ueber Query-Parameter auf dem **gleichen** Endpunkt:
| Modus | Query-Parameter | Response | Zweck |
|-------|----------------|----------|-------|
| Normal (default) | `pagination={...}` | `{ items: T[], pagination: PaginationMetadata }` | Paginierte Datenliste |
| `filterValues` | `mode=filterValues&column=xxx&pagination={crossFilters}` | `string[]` | Distinct-Werte fuer Spaltenfilter-Dropdown |
| `ids` | `mode=ids&pagination={filters}` | `string[]` | Alle IDs der gefilterten Datensaetze |
**Kein separater `/filter-values`-Endpunkt.** Alle alten `/filter-values`-Sub-Pfade wurden entfernt (Stand 2026-04-13).
### Cross-Filtering
Bei `mode=filterValues` werden alle aktiven Filter **ausser** dem angeforderten Column-Filter angewendet (Cross-Filtering). So zeigt z.B. der Status-Filter nur Werte an, die bei den aktuell gesetzten Mandant- und Typ-Filtern vorkommen.
### Beispiele
```
# Normale Liste (Seite 1, 20 Eintraege)
GET /api/users/?pagination={"page":1,"pageSize":20,"sort":[{"field":"name","direction":"asc"}]}
# Filter-Werte fuer Spalte "status" (mit Cross-Filter auf mandateLabel)
GET /api/users/?mode=filterValues&column=status&pagination={"filters":{"mandateLabel":"Demo AG"}}
# Alle IDs der gefilterten Ansicht (fuer "Select All Filtered")
GET /api/users/?mode=ids&pagination={"filters":{"status":"active"},"search":"admin"}
```
### Backend-Implementierung
Zentrale Hilfsfunktionen in `platform-core/modules/routes/routeHelpers.py` (FK-Label-Aufloesung: siehe [FK Label Resolution](../platform-core/fk-label-resolution.md)):
| Funktion | Zweck |
|----------|-------|
| `handleFilterValuesMode(db, modelClass, column, ...)` | SQL-basierte Distinct-Werte via DB-Connector |
| `handleIdsMode(db, modelClass, ...)` | SQL-basierte ID-Liste via DB-Connector |
| `handleFilterValuesInMemory(items, column, ...)` | In-Memory Distinct-Werte (fuer enriched/aggregierte Listen) |
| `handleIdsInMemory(items, ...)` | In-Memory ID-Liste |
| `parseCrossFilterPagination(column, paginationJson)` | Parsed Pagination-JSON und entfernt den angeforderten Column-Filter |
| `_applyFiltersAndSort(items, paginationParams)` | In-Memory Filterung und Sortierung |
| `_extractDistinctValues(items, column, ...)` | Distinct-Werte aus Item-Liste extrahieren |
| `paginateInMemory(items, paginationParams)` | In-Memory Paginierung |
### Route-Integration Pattern
```python
@router.get("/", response_model=PaginatedResponse[MyModel])
def list_items(
request: Request,
pagination: Optional[str] = Query(None),
mode: Optional[str] = Query(None, description="'filterValues' or 'ids'"),
column: Optional[str] = Query(None, description="Column key"),
context: RequestContext = Depends(getRequestContext),
):
if mode in ("filterValues", "ids"):
from modules.routes.routeHelpers import handleFilterValuesInMemory, handleIdsInMemory
items = _buildFullList(context)
if mode == "filterValues":
if not column:
raise HTTPException(status_code=400, detail="column required")
return handleFilterValuesInMemory(items, column, pagination)
return handleIdsInMemory(items, pagination)
# Normal paginated list...
```
---
## Selektion
### ID-basierte Selektion
Die Selektion arbeitet mit **IDs** (nicht Zeilen-Indizes). Das `idField`-Prop bestimmt, welches Feld als eindeutiger Schluessel verwendet wird (Default: `"id"`).
| State | Typ | Beschreibung |
|-------|-----|-------------|
| `selectedIds` | `Set<string>` | Menge der selektierten IDs |
### Selektion-Reset
Die Selektion wird automatisch zurueckgesetzt bei:
- Seitenwechsel (currentPage)
- Seitengroesse-Aenderung (pageSize)
- Filter-Aenderung
- Suchbegriff-Aenderung
- Sortier-Aenderung
### Select All Filtered
Wenn `apiEndpoint` und `supportsBackendPagination` gesetzt sind, zeigt die Toolbar einen "Alle X Eintraege auswaehlen"-Button. Dieser ruft `GET {apiEndpoint}?mode=ids&pagination={currentFilters}` auf und fuegt alle zurueckgegebenen IDs zur Selektion hinzu.
Ein "Auswahl aufheben"-Button erscheint, wenn "Select All Filtered" aktiv ist.
### Row-Level Delete
Wenn eine Zeile geloescht wird (via `actionButtons` Delete), wird deren ID automatisch aus `selectedIds` entfernt.
---
## FilterValuesList (Spaltenfilter-Dropdown)
Wenn ein filterbarer Spalten-Header geklickt wird, laedt das Dropdown die Distinct-Werte:
1. `column.filterOptions` (statische Enum, page-defined) -- wird direkt verwendet, kein Backend-Call
2. `column.options` (kommt aus `frontend_options` der Pydantic-Felder, automatisch via `resolveColumnTypes`) -- wird direkt verwendet
3. `hookData.fetchFilterValues(columnKey, crossFilters)` -- falls im Hook bereitgestellt
4. `GET {apiEndpoint}?mode=filterValues&column=xxx&pagination={currentFilters}` -- automatisch
Bei mehr als 10 Werten erscheint ein **Suchfeld** (Client-seitige Filterung der geladenen Werte).
Boolean-Spalten rendern als "Ja"/"Nein". Datum-Spalten rendern als Range-Picker (Von/Bis, `type` date/timestamp/time). Zahlenspalten (`integer`/`int`/`number`/`float`) rendern als Operatorauswahl (=, >, >=, <, <=, Zwischen) plus Wertfeld(er) und `Anwenden`; Filter-Payload: `{ operator, value }` bzw. `{ operator: "between", value: { from, to } }` (kompatibel mit `routeHelpers._matchesFilter` / SQL-Pagination).
---
## Cell-Rendering: KEINE hardcoded Labels in Pages
**Regel:** Pages duerfen Value->Label-Mappings NICHT im Frontend hardcoden (kein `formatter: (v) => v ? 'Ja' : 'Nein'`, kein `_STATUS_LABELS[v]`, kein `scopeLabels[v]`). Daten und Display-Labels kommen ausschliesslich aus dem Backend-Modell.
### Pattern fuer Booleans
Im Pydantic-Feld:
```python
active: bool = Field(
default=True,
json_schema_extra={
"frontend_type": "checkbox",
"label": "Aktiv",
"frontend_format_labels": ["Ja", "-", "Nein"], # [TRUE, NULL, FALSE]
},
)
```
Im Frontend-Page reicht:
```typescript
{ key: 'active', sortable: true, filterable: true }
```
Kein `formatter`, kein hardcoded `Ja`/`Nein`. `FormGeneratorTable.renderBooleanCell` greift automatisch und nutzt die i18n-aufgeloesten Labels.
### Pattern fuer Enums (`select`-Felder)
Im Pydantic-Feld:
```python
status: str = Field(
default="running",
json_schema_extra={
"frontend_type": "select",
"label": "Status",
"frontend_options": [
{"value": "running", "label": "Läuft"},
{"value": "completed", "label": "Abgeschlossen"},
{"value": "failed", "label": "Fehlgeschlagen"},
],
},
)
```
Im Frontend-Page reicht:
```typescript
{ key: 'status', sortable: true, filterable: true }
```
`resolveColumnTypes` merged `options` automatisch in `ColumnConfig.options`. `FormGeneratorTable` resolved Cell-Value -> Label und Filter-Dropdown-Labels aus diesen Options. Der `i18nRegistry.resolveText` uebersetzt die Labels server-seitig basierend auf der Sprache des Requests.
### Pattern fuer Header-Labels
`column.label` ist optional. `resolveColumnTypes` zieht das Label aus dem Backend-Attribut (`label` aus `json_schema_extra`). Page setzt `label` nur, um den Backend-Default zu ueberschreiben.
### Anti-Pattern (nicht erlaubt)
```typescript
// FALSCH — hardcoded Labels gehoeren NICHT in die Page
formatter: (v: boolean) => v ? <span>Ja</span> : <span>Nein</span>
formatter: (v: string) => statusMap[v] ?? v
filterLabelResolver: (v: string) => myLabels[v]
```
Wenn das Backend keine `frontend_options`/`frontend_format_labels` setzt: **Pydantic-Modell erweitern, NICHT die Page hacken.**
---
## Migrierte Endpunkte (Stand 2026-04-13)
Alle folgenden Endpunkte unterstuetzen `mode=filterValues` und `mode=ids`:
### Core Routes
| Route-Datei | Endpunkt | Methode |
|-------------|----------|---------|
| `routeDataUsers.py` | `GET /api/users/` | SQL + In-Memory (je nach Scope) |
| `routeDataConnections.py` | `GET /api/connections/` | In-Memory |
| `routeDataPrompts.py` | `GET /api/prompts/` | In-Memory |
| `routeInvitations.py` | `GET /api/invitations/` | In-Memory |
| `routeSubscription.py` | `GET /api/subscriptions/admin/all` | In-Memory |
| `routeAdminFeatures.py` | `GET /api/admin/features/instances` | In-Memory |
| `routeAdminFeatures.py` | `GET /api/admin/features/templates/roles` | In-Memory |
| `routeAdminFeatures.py` | `GET /api/admin/features/instances/{id}/users` | In-Memory |
| `routeAdminRbacRules.py` | `GET /api/admin/rbac/roles` | In-Memory |
| `routeDataMandates.py` | `GET /api/mandates/` | SQL + In-Memory (je nach Scope) |
| `routeDataMandates.py` | `GET /api/mandates/{id}/users` | In-Memory |
| `routeDataFiles.py` | `GET /api/files/list` | SQL + In-Memory Fallback |
| `routeWorkflowDashboard.py` | `GET /api/automation/dashboard/` (Runs) | Enriched + SQL |
| `routeWorkflowDashboard.py` | `GET /api/automation/dashboard/workflows` | Enriched + SQL |
| `routeBilling.py` | `GET /api/billing/view/users/transactions` | Billing-Interface |
### Feature Routes
| Route-Datei | Endpunkt | Methode |
|-------------|----------|---------|
| `routeFeatureTrustee.py` | `GET /api/trustee/{id}/documents` | RBAC SQL + In-Memory Fallback |
| `routeFeatureTrustee.py` | `GET /api/trustee/{id}/positions` | RBAC SQL + In-Memory Fallback |
| `routeFeatureRealEstate.py` | `GET /api/realestate/{id}/projects` | In-Memory |
| `routeFeatureRealEstate.py` | `GET /api/realestate/{id}/parcels` | In-Memory |
---
## FormGeneratorTree
`FormGeneratorTree` ist die generische Baumkomponente der FormGenerator-Familie.
Sie ersetzt die fruehere `FolderTree`-Komponente und folgt einem Provider-Pattern,
bei dem die Datenlogik (API-Aufrufe, CRUD-Operationen) in austauschbare
`TreeNodeProvider`-Implementierungen ausgelagert wird.
### Dateien
| Datei | Inhalt |
|-------|--------|
| `FormGeneratorTree.tsx` | Haupt-Komponente (Rendering, Selection, DnD, Inline-Editing) |
| `FormGeneratorTree.module.css` | Styles |
| `types.ts` | `TreeNode`, `TreeNodeProvider`, `TreeBatchAction`, `FormGeneratorTreeProps` |
| `providers/FolderFileProvider.tsx` | Referenz-Provider fuer Files + Folders |
### TreeNodeProvider-Interface
```typescript
interface TreeNodeProvider<T = any> {
rootKey: string;
loadChildren(parentId: string | null, ownership: Ownership): Promise<TreeNode<T>[]>;
canCreate?(parentId: string | null): boolean;
canRename?(node: TreeNode<T>): boolean;
canDelete?(node: TreeNode<T>): boolean;
canMove?(source: TreeNode<T>, target: TreeNode<T> | null): boolean;
canPatchScope?(node: TreeNode<T>): boolean;
canPatchNeutralize?(node: TreeNode<T>): boolean;
createChild?(parentId: string | null, name: string): Promise<TreeNode<T>>;
renameNode?(id: string, newName: string): Promise<void>;
deleteNodes?(ids: string[]): Promise<void>;
moveNodes?(ids: string[], targetParentId: string | null): Promise<void>;
patchScope?(ids: string[], scope: ScopeValue, cascadeChildren?: boolean): Promise<void>;
patchNeutralize?(ids: string[], neutralize: boolean): Promise<void>;
getBatchActions?(): TreeBatchAction[];
}
```
Die `can*`-Methoden steuern, welche Affordances pro Knoten sichtbar sind.
Alle Mutations-Methoden sind optional -- ein read-only Provider implementiert nur
`loadChildren`.
### Built-in Features
- **Expand/Collapse** mit Lazy-Load via `loadChildren`
- **Multiselect** mit Shift/Ctrl; getrennt zwischen `ownership='own'` und `'shared'`
- **Cascade-Selection**: Folder selektieren -> alle eigenen Children selektiert
- **DnD** (`application/x-poweron-tree-items`); aus `shared` nur lesende Drops
- **Inline-Rename** (Doppelklick / F2) -- nur fuer eigene Knoten
- **Scope/Neutralize** Icons: interaktiv fuer eigene, Indikator fuer geteilte
- **Mixed-State** (generisch): `scope`, `neutralize` und beliebige `extraActions[*].value`
duerfen den Wert `'mixed'` haben (vom Provider/Backend gesetzt). Der Tree rendert
dann ein uniformes Symbol (`\u25E9`) statt des typspezifischen Icons.
- **Pending-Spinner** (generisch): waehrend einer asynchronen Provider-Aktion
(`patchScope`, `patchNeutralize`, oder `extraActions[*].onClick`) zeigt der Tree
einen kleinen rotierenden Spinner ueber dem jeweiligen Button.
- **extraActions** (generisch): Knoten koennen eine Liste zusaetzlicher Aktionen
mitbringen (`TreeNode.extraActions: NodeAction[]`). Der Tree rendert sie als
Icon-Buttons mit Tooltip; er kennt deren Semantik nicht und reicht nur Klicks
in den Pending-Spinner-Wrapper.
- **Batch-Actions** mit `typeFilter` (z.B. separate Delete-Buttons fuer Ordner vs. Dateien)
- **Refresh-Button** im Section-Header
- **Confirmation-Dialoge** vor Delete-Aktionen
### Generische Erweiterungen (Mixed, Pending, extraActions)
```typescript
interface NodeAction {
key: string; // eindeutig pro Node
icon: React.ReactNode; // Standard-Icon (durch mixed/spinner ueberschrieben)
tooltip: string;
value?: boolean | string | 'mixed';
disabled?: boolean;
onClick?: () => Promise<void> | void;
}
interface TreeNode<T> {
// ...
scope?: ScopeValue | 'mixed';
neutralize?: boolean | 'mixed';
extraActions?: NodeAction[];
}
```
Rendering-Regeln:
| Zustand | Anzeige |
|---------|--------|
| `value !== 'mixed'`, kein Pending | typspezifisches Icon (Scope-Emoji, Schloss, Action-Icon) |
| `value === 'mixed'` | `\u25E9` mit Klasse `flagMixed` (uniform fuer alle Flags) |
| Pending (Promise laeuft) | rotierender Spinner mit Klasse `flagSpinner` |
Diese Features sind domaen-frei und werden von beiden FormGeneratorTree-Konsumenten
(`FilesTab.tsx` und `UnifiedDataBar/SourcesTab.tsx`) genutzt.
### Props (`FormGeneratorTreeProps`)
| Prop | Typ | Beschreibung |
|------|-----|-------------|
| `provider` | `TreeNodeProvider<T>` | Daten- und Operations-Provider |
| `ownership` | `'own' \| 'shared'` | Bestimmt Tree-Kontext (CRUD vs. read-only) |
| `title` | `string?` | Section-Header |
| `compact` | `boolean?` | Kompakte Darstellung (z.B. in UDB) |
| `collapsible` | `boolean?` | Section kollabierbar |
| `defaultCollapsed` | `boolean?` | Initial kollabiert |
| `selectable` | `boolean?` | Default `true`. Bei `false` keine Checkboxes, kein Batch-Toolbar (UDB-Sources-Modus) |
| `emptyMessage` | `string?` | Anzeige bei leerem Tree |
| `onNodeClick` | `(node) => void` | Callback bei Knoten-Klick |
| `onSelectionChange` | `(selectedIds) => void` | Callback bei Selektions-Aenderung |
| `onRefresh` | `() => void` | Callback nach internem Refresh (z.B. fuer Tabellen-Sync) |
### Verwendung
Aktuell in drei Kontexten eingebunden, jeweils mit eigenem Provider:
- **`UnifiedDataBar/FilesTab.tsx`**: Zwei Sektionen (Eigene / Geteilt mit mir), Provider: `FolderFileProvider`
- **`UnifiedDataBar/SourcesTab.tsx`**: Zwei Sektionen (Persoenliche Quellen / Mandanten-Daten),
Provider: `createUdbPersonalProvider` + `createUdbFeatureProvider` aus `components/UnifiedDataBar/sources/`
- **`pages/basedata/FilesPage.tsx`**: Split-View mit Tree links und `FormGeneratorTable` rechts;
Tree-Selektion filtert Tabelle nach Ordner
### Tree vs. Table-with-Grouping
| Kriterium | `FormGeneratorTree` | `FormGeneratorTable` mit `groupingConfig` |
|-----------|--------------------|--------------------------------------------|
| Persistenz | Knoten sind persistente Entitaeten (z.B. Folders) | Gruppen sind UI-only JSON (`TableGrouping`) |
| CRUD | Create/Rename/Delete/Move pro Knoten | Kein CRUD auf Gruppen-Ebene |
| RBAC | Scope/Neutralize pro Knoten, Owner-Guards | Keine pro-Gruppen-Berechtigungen |
| Einsatz | Hierarchische Datenstrukturen | Flache Listen mit visueller Kategorisierung |