Skip to content

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 siehe ui-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,
},
],
}],
},
});
FunktionEffektSignatur
visibleFeld wird angezeigt/versteckt(data, ctx) => boolean
readonlyFeld wird readonly/editierbar(data, ctx) => boolean
requiredFeld 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 data gegen das Entity-Schema
  • Kein DSL das man lernen muss — es ist TypeScript
  • ctx gibt 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 filtern

Framework rendert Feld-Typen intelligent:

  • boolean → Toggle/Switch
  • text mit format: "email" → Input mit Icon
  • select → 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.

LevelWill ich…LoesungRenderer-spezifisch?
1Spalten/Felder konfigurierenConfig (layout, columns)Nein — Datenstruktur
2Ein Feld anders darstellenField RendererJa — JSX pro Renderer
3Listenzeile anders darstellenRow/Card RendererJa — JSX pro Renderer
4Teile des Screens ersetzenSlotsJa — JSX pro Renderer
5Alles andersCustom ScreenJa — 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:

QuelleSyntaxBeispiel
Eigenes FeatureDirekter Importrenderer: { react: OrderStateBadge }
Anderes FeatureString via QN-Referenzrenderer: "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:

SlotWoDefault
headerGanz obenEntity-Titel + Breadcrumbs
beforeFormZwischen Header und FormularNichts
afterFormUnter dem FormularNichts
sidebarRechte Seite (Desktop only)Nichts
footerGanz untenSave/Cancel Buttons
toolbarButton-Leiste im HeaderStandard-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-TypEntityCRUDScreen-TypBeispiel
StandardentityList + entityEditUser Management
Query-onlycustomDashboard, Reports
HybridMix aus entity + customKanban mit Entity-Backend
UI-onlycustomHilfe-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 aus data.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: expiresAt setzbar; 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.list mit 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