UI Architektur — Screens, Layouts, Forms
Zusammenhang: Dieses Dokument beschreibt die Screen-/Form-/Layout-Patterns fuer Feature-Devs. Fuer die darunterliegende Renderer-Architektur (Package-Struktur, Core vs. Renderer, Primitives, Hooks, Localization, Styling) siehe
ui-renderer.md. Fuer Cross-Feature Components, Actions und Screen Extensions sieheui-components.md.
Responsive: useLayout() Hook
Drei Breakpoints, eine Codebase. Jede Komponente passt sich an.
const layout = useLayout(); // "compact" | "medium" | "expanded"// compact: < 600px (Phone)// medium: 600-1200px (Tablet, schmales Browser-Fenster)// expanded: > 1200px (Desktop)Tailwind-Breakpoints nutzen die gleichen Namen:
<div className="grid grid-cols-1 medium:grid-cols-2 expanded:grid-cols-3" />Tenant-Switcher (Slack-Style)
User kann in mehreren Tenants sein (N:M). Sidebar zeigt aktiven Tenant mit Logo, Klick oeffnet Dropdown mit allen Tenants. Auf Web zusaetzlich Tenant-Key in URL fuer Bookmarks: app.example.com/t/{tenant-key}/...
List/Detail: Hybrid
Framework entscheidet anhand von Layout automatisch:
expanded (Desktop): compact (Mobile):┌──────────┬─────────────────┐ ┌──────────────────┐│ Liste │ Detail │ │ Liste ││ -------- │ │ │ Item 1 ││ Item 1 ←│ [Gewaehltes] │ │ Item 2 → [tap] ││ Item 2 │ │ │ Item 3 ││ Item 3 │ │ └──────────────────┘└──────────┴─────────────────┘ ↓ Navigation ┌──────────────────┐ │ ← Detail │ │ [Gewaehltes Item]│ └──────────────────┘Das Feature definiert nur entityList + entityEdit Screens — der Renderer rendert den Split oder die Navigation.
Form Layout System
Features definieren Sektionen und Spalten:
r.screen({ id: "userEdit", type: "entityEdit", layout: { sections: [ { title: "users:section.personal", columns: 2, // 2-spaltig auf expanded/medium, 1-spaltig auf compact fields: ["firstName", "lastName", "email", "phone"], }, { title: "users:section.access", columns: 1, fields: ["userRoles", "employmentType", "isEnabled"], }, { title: "users:section.address", columns: 3, fields: [ "street", { field: "zipCode", span: 1 }, { field: "city", span: 2 }, ], }, ], },});Conditional Fields: visible, readonly, required
Felder koennen dynamisch sichtbar, editierbar oder pflicht sein — gesteuert durch Funktionen mit Zugriff auf aktuelle Daten und User-Context:
r.screen({ id: "orderEdit", type: "entityEdit", layout: { sections: [{ title: "orders:section.details", columns: 2, fields: [ "orderType", { field: "expressSurcharge", visible: (data) => data.orderType === "express", }, { field: "scheduledDate", visible: (data) => data.orderType !== "express", }, { field: "client", readonly: (data) => ["delivered", "completed"].includes(data.state), }, { field: "pickup", readonly: (data, ctx) => { if (ctx.user.roles.includes("Admin")) return false; return data.state !== "new"; }, }, { field: "deliveryComment", visible: (data) => data.state === "delivered", readonly: (data) => data.state === "completed", required: (data) => data.personalDelivery === true, }, ], }], },});| Funktion | Effekt | Signatur |
|---|---|---|
visible | Feld wird angezeigt/versteckt | (data, ctx) => boolean |
readonly | Feld wird readonly/editierbar | (data, ctx) => boolean |
required | Feld wird Pflicht/optional | (data, ctx) => boolean |
Der Form-Controller (siehe ui-renderer.md) evaluiert diese Funktionen bei jedem setField neu. Invisible Felder werden bei der Zod-Validierung ausgeschlossen.
Vorteile gegenueber einer DSL (showWhen: { field: "value" }):
- Beliebig komplex (State + Rolle + andere Felder + Config)
- TypeScript prueft
datagegen das Entity-Schema - Kein DSL das man lernen muss — es ist TypeScript
ctxgibt Zugriff auf User, Rollen, Config
Relation Picker mit Context
Wenn ein Feld eine Relation ist und der Picker Kontext aus dem Formular braucht:
r.relation("order", "client", { type: "belongsTo", target: "client", foreignKey: "clientId", pickerContext: ["displayPickupDateTime"], // Diese Felder werden dem Picker mitgegeben});
// Framework gibt dem ClientPicker automatisch:// <ClientPicker value={clientId} onChange={...} context={{ displayPickupDateTime: "2026-04-01" }} />// → Picker kann nach Verfuegbarkeit an diesem Datum filternFramework rendert Feld-Typen intelligent:
boolean→ Toggle/Switchtextmitformat: "email"→ Input mit Iconselect→ Dropdown (Web) / Bottom Sheet Picker (Mobile)bitmask→ Chip-Gruppe
Renderer Customization: 5 Levels
Man faengt bei Level 1 an und geht nur so weit runter wie noetig. Kein Alles-oder-Nichts.
| Level | Will ich… | Loesung | Renderer-spezifisch? |
|---|---|---|---|
| 1 | Spalten/Felder konfigurieren | Config (layout, columns) | Nein — Datenstruktur |
| 2 | Ein Feld anders darstellen | Field Renderer | Ja — JSX pro Renderer |
| 3 | Listenzeile anders darstellen | Row/Card Renderer | Ja — JSX pro Renderer |
| 4 | Teile des Screens ersetzen | Slots | Ja — JSX pro Renderer |
| 5 | Alles anders | Custom Screen | Ja — JSX pro Renderer |
Level 2: Field Renderer
Einzelne Felder anders darstellen — eigene Component oder Formatter:
import { OrderStateBadge } from "./components/OrderStateBadge";
r.screen({ id: "orderList", type: "entityList", columns: [ { field: "identifier" }, { field: "state", renderer: { react: OrderStateBadge, // Web native: OrderStateBadgeMobile, // optional, sonst Fallback }}, { field: "client", renderer: "clients:component:client-badge" }, // Cross-Feature via QN { field: "price", renderer: (value) => `${value} €` }, // Einfacher Formatter { field: "createdAt", renderer: (value) => relativeTime(value) }, ],});Zwei Quellen fuer Renderer:
| Quelle | Syntax | Beispiel |
|---|---|---|
| Eigenes Feature | Direkter Import | renderer: { react: OrderStateBadge } |
| Anderes Feature | String via QN-Referenz | renderer: "clients:component:client-badge" |
Boot-Validierung prueft ob QN-Referenzen existieren und fuer den aktiven Renderer verfuegbar sind.
Level 3: Row/Card Renderer
Statt Standard-Tabellenzeile eine eigene Darstellung:
import { OrderListRow } from "./components/OrderListRow";import { OrderCard } from "./components/OrderCard";
r.screen({ id: "orderList", type: "entityList", rowRenderer: { react: OrderListRow, // Eigene Zeile (Desktop) }, cardRenderer: { react: OrderCard, // Eigene Karte (Mobile) native: OrderCardNative, },});Ohne rowRenderer/cardRenderer: Renderer malt Standard-Tabelle/Karten aus den columns.
Level 4: Slots
Teile eines Standard-Screens ersetzen ohne den ganzen Screen custom zu bauen:
r.screen({ id: "orderEdit", type: "entityEdit", layout: { sections: [...] }, // Formular bleibt config-driven slots: { header: { react: OrderEditHeader }, // Status-Timeline statt Standard-Header beforeForm: { react: OrderWarnings }, // Warnungen ueber dem Formular afterForm: { react: OrderActivityFeed }, // History unter dem Formular sidebar: { react: OrderRelatedItems }, // Seitenpanel (nur Desktop) footer: { react: OrderEditFooter }, // Eigene Footer-Buttons },});Verfuegbare Slots:
| Slot | Wo | Default |
|---|---|---|
header | Ganz oben | Entity-Titel + Breadcrumbs |
beforeForm | Zwischen Header und Formular | Nichts |
afterForm | Unter dem Formular | Nichts |
sidebar | Rechte Seite (Desktop only) | Nichts |
footer | Ganz unten | Save/Cancel Buttons |
toolbar | Button-Leiste im Header | Standard-Actions |
Nicht belegte Slots: Renderer malt Default oder nichts.
Zusammenspiel: Alle Levels kombinierbar
r.screen({ id: "orderEdit", type: "entityEdit", // Level 1: Config layout: { sections: [{ title: "orders:section.details", columns: 2, fields: [ "identifier", { field: "state", renderer: { react: OrderStateBadge } }, // Level 2 { field: "client", renderer: "clients:component:client-badge" }, { field: "pickup", readonly: (data) => data.state !== "new" }, ], }], }, // Level 4: Slots slots: { header: { react: OrderEditHeader }, afterForm: { react: OrderHistory }, }, // Level 5 waere: type: "custom", renderer: { react: MyScreen }});Custom Screens + Sub-Routing
Screen-Renderer sind explizit — kein Magic Path, kein String-Mapping:
import { DashboardOverview } from "./screens/DashboardOverview";import { RevenueDetail } from "./screens/RevenueDetail";
export default defineFeature("dashboard", (r) => { r.queryHandler("stats", schema, async (query, ctx) => { return { revenue: 42000, openTickets: 12 }; });
r.screen({ id: "dashboard", type: "custom", renderer: { react: DashboardOverview }, routes: [ { path: "/revenue", component: { react: RevenueDetail } }, { path: "/tickets", component: { react: TicketBoard } }, ], access: { roles: ["Admin"] }, });
r.nav({ id: "dashboard", label: "dashboard:nav.title", icon: "chart-bar", screen: "dashboard:screen:dashboard", access: { roles: ["Admin"] }, });});Warum explizit:
- Expo Router nutzt schon File-based Routing — zwei Systeme kollidieren
- TypeScript prueft den Import — Compile-Error statt Runtime-Error
- Konsistent mit writeHandler/queryHandler
Framework liefert:
- URL-Routing (Web):
/t/{tenant}/dashboard/revenue - Stack Navigation (Mobile): innerhalb des Screens
- Breadcrumbs: automatisch aus route path
- Shell bleibt (Sidebar, Topbar, Auth) — siehe Shell als Feature
Framework Hooks fuer Custom Screens:
export function DashboardOverview() { const { data } = useCommand("dashboard:query:stats", {}); const layout = useLayout(); const { t } = useTranslation("dashboard"); const { navigate } = useNav(); const config = useConfig("dashboard"); const theme = useTheme();
return ( <View className="p-md"> <Text className="font-bold text-primary">{t("nav.title")}</Text> <StatsCard revenue={data?.revenue} /> <Button onPress={() => navigate("dashboard:screen:revenue")}>Details</Button> </View> );}Siehe Hooks und State fuer die vollstaendige Hook-API.
Feature-Typen
| Feature-Typ | Entity | CRUD | Screen-Typ | Beispiel |
|---|---|---|---|---|
| Standard | ✅ | ✅ | entityList + entityEdit | User Management |
| Query-only | ❌ | ❌ | custom | Dashboard, Reports |
| Hybrid | ✅ | ✅ | Mix aus entity + custom | Kanban mit Entity-Backend |
| UI-only | ❌ | ❌ | custom | Hilfe-Seiten, Onboarding Wizard |
Post-Todos (nach UI-Bau)
Custom Fields — Rendering in Host-Entities
Wenn das customFields-Feature auf einer Entity aktiv ist, muessen UI-Komponenten die Custom-Field-Definitionen des Tenants laden und dynamisch rendern. Fuer den Nutzer darf kein Unterschied zu Stammfeldern erkennbar sein.
- Form-Renderer: nach den Stammfeldern eine Sektion “Zusatzfelder” (oder konfigurierbarer Platz) mit Widget pro Feld-Typ (
text,number,boolean,date,enum,money,embedded). Werte kommen ausdata.customFields[fieldKey], Writes landen unter demselben Pfad. - List-Column-Renderer: optionale Spalten pro Custom Field; Spalten-Chooser zeigt Stamm- und Custom-Felder gemischt. Sort/Filter auf Custom Fields laeuft in v1 ausschliesslich ueber Search.
- Filter-Renderer: Filter-UI fuer Custom Fields aus den Definitionen generieren.
- FieldAccess: Write-Sperre im Form pro Rolle, Read-Filter in Listen — genau wie bei Stammfeldern.
Siehe: features/custom-fields.md
Feature Toggles — System-Operator UI
SaaS-Operator-Oberflaeche zum Umlegen der Toggles pro Tenant.
- Tenant-Auswahl → Liste aller registrierten Features mit: Name, Default, aktueller Zustand (an/aus/Cascade-aus),
requires-Abhaengigkeiten sichtbar - Toggle-Switch; bei Cascade-aus (Dependency fehlt) ist der Switch read-only mit Hinweis welches Feature fehlt
- Nav-/Handler-Zahl pro Feature zur Orientierung (“betrifft 3 Handler, 2 Screens”)
- Batch-Ansicht: alle Tenants × alle Toggles fuer Ueberblick
- Nicht-toggleable Features werden grau angezeigt (nicht versteckt — Transparenz)
Sichtbarkeit: nur SystemAdmin-Rolle. Nicht im regulaeren Tenant-Admin-UI.
Siehe: features/core-feature-toggles.md
Business Model — System-Operator Plan-Management
- Plan-Verwaltung: CRUD ueber Pläne. Form zeigt Feature-Checklist aus
featureToggles.registered. Label i18n. - Tenant-Plan-Zuordnung: Pro Tenant den aktiven Plan sehen + aendern. Assign-Dialog zeigt Diff (“diese Features kommen hinzu / gehen weg”), erzwingt v1-Policy “ein Plan pro Tenant”.
- Trial:
expiresAtsetzbar; Tenant-Liste markiert auslaufende Trials visuell. - Plan-Wechsel-Historie: aus Audit-Trail aufbereitet (keine eigene Tabelle noetig).
Spaeter: Self-Service-Upgrade-UI fuer Tenant-Admin (haengt an Billing-Feature).
Siehe: features/business-model.md
Data Retention — Tenant-Admin UI
- Onboarding-Banner: bei erstem TenantAdmin-Login wird Preset-Wahl erzwungen (Modal). Liste der verfuegbaren Presets aus
retention.presets.listmit Beschreibung pro Preset. - Settings-Screen “Retention & Compliance”:
- Aktueller Preset, Wechsel-Button mit Diff-Vorschau (“nach Wechsel werden Sessions nach 30 Tagen statt 90 Tagen geloescht”)
- Liste der Override-Eintraege (Layer 3) — pro Entity zeigen wenn vom Preset abgewichen wird
- Override-Form: Entity, KeepFor, Strategy, Reason — mit Compliance-Hinweis (“Eigene Aenderung kann gesetzliche Pflichten verletzen”)
- Effective-Policy-Anzeige: pro Entity sehen “wie lange werden Daten gehalten / wann werden sie geloescht oder anonymisiert?”. Aufruf von
retention.policy.effective. - Retention-Report: monatliche/jaehrliche Aggregat-Sicht “X Datensaetze geloescht, Y anonymisiert” — fuer DPO-Doku.
Sichtbarkeit: TenantAdmin nur fuer eigenen Tenant, SystemAdmin fuer alle.
Siehe: features/core-data-retention.md
Read-Access-Log — Reports
- Self-Audit-Screen fuer User (DSGVO): “Wer hat meine Daten gelesen?” — Liste mit aggregierten Reads pro Lesendem User, Datum, Entity-Typ, Anlass (handlerName humanisiert)
- TenantAdmin-Compliance-Screen:
- “Was hat User X im Zeitraum gelesen?”
- “Wer hat Entity Y gelesen?”
- Filter: Datum, Source (db/search), Handler
- Aggregat-Reports: Top-10 meist-gelesene Entities, Reads pro Source-Pfad, Trend-Charts
- Anomalie-Alerts (v2): Banner wenn ungewoehnliche Aktivitaet erkannt
Sichtbarkeit: Self-Audit fuer alle User, Compliance-Screen fuer TenantAdmin/ComplianceOfficer.
Siehe: features/core-read-access-log.md
Custom Fields — Tenant-Admin UI
Tenant-Admin-Screen zum Verwalten der fieldDefinition-CRUD:
- Liste der Custom Fields pro Entity (gruppiert nach
entityName) - Anlegen/Bearbeiten: Typ, Label (i18n), Required, Default, Options (bei enum/embedded), Searchable, FieldAccess (Rollen), DisplayOrder
- Reihenfolge per Drag & Drop
- Loeschen mit Bestaetigung (Folgen: bestehende Values bleiben bis Cleanup-Job)
- Preview: wie sieht das Feld im Form / in der Liste aus