diff --git a/backend/migrations/016_workflows_graph_data.sql b/backend/migrations/016_workflows_graph_data.sql
new file mode 100644
index 0000000..4e4f9ee
--- /dev/null
+++ b/backend/migrations/016_workflows_graph_data.sql
@@ -0,0 +1,35 @@
+-- Migration 016: Workflow Support in ai_prompts (Phase 5)
+-- Erweitert ai_prompts für type='workflow' (Option B - Backward-kompatibel)
+
+-- Neue Spalte für Workflow-Graphen
+ALTER TABLE ai_prompts ADD COLUMN graph_data JSONB;
+
+-- Index für Workflow-Queries
+CREATE INDEX IF NOT EXISTS idx_ai_prompts_type ON ai_prompts(type);
+CREATE INDEX IF NOT EXISTS idx_ai_prompts_graph_data ON ai_prompts USING GIN (graph_data);
+
+-- Kommentar
+COMMENT ON COLUMN ai_prompts.graph_data IS 'Workflow-Graph (nur für type=workflow): {nodes: [...], edges: [...], metadata: {...}}';
+
+-- Beispiel-Struktur (Dokumentation):
+-- {
+-- "nodes": [
+-- {"id": "start", "type": "start", "label": "Start", "position": {"x": 100, "y": 100}},
+-- {"id": "analysis_1", "type": "analysis", "prompt_id": 5, "questions": [...], ...},
+-- {"id": "logic_1", "type": "logic", "condition": {...}, ...},
+-- {"id": "join_1", "type": "join", "join_strategy": "wait_all", ...},
+-- {"id": "end", "type": "end", "label": "Ende", "position": {"x": 500, "y": 300}}
+-- ],
+-- "edges": [
+-- {"id": "e1", "source": "start", "target": "analysis_1"},
+-- {"id": "e2", "source": "analysis_1", "target": "logic_1"},
+-- {"id": "e3", "source": "logic_1", "target": "join_1"},
+-- {"id": "e4", "source": "join_1", "target": "end"}
+-- ],
+-- "metadata": {
+-- "created_at": "2026-04-04T12:00:00Z",
+-- "version": "1.0"
+-- }
+-- }
+
+-- Migration erfolgreich
diff --git a/backend/prompt_executor.py b/backend/prompt_executor.py
index 5352ef7..769a8d9 100644
--- a/backend/prompt_executor.py
+++ b/backend/prompt_executor.py
@@ -588,11 +588,11 @@ async def execute_workflow_prompt(
"""
Execute a workflow-type prompt (graph-based execution).
- Phase 2: Sequenzielle Workflow-Execution (ohne Logik/Routing)
- Phase 3: Conditional branching
+ Phase 2-4: Sequenzielle Workflow-Execution, conditional branching, path consolidation
+ Phase 5: Graph aus ai_prompts.graph_data (nicht workflow_definitions)
Args:
- prompt: Prompt dict from database (must have 'id' field for workflow_id)
+ prompt: Prompt dict from database (must have 'graph_data' field)
variables: Dict of variables for placeholder replacement
openrouter_call_func: Async function(prompt_text, model) -> response_text
enable_debug: If True, include debug information in response
@@ -611,13 +611,14 @@ async def execute_workflow_prompt(
"""
from workflow_executor import execute_workflow
- workflow_id = prompt.get('id')
- if not workflow_id:
- raise HTTPException(400, "Workflow-Prompt fehlt 'id' Feld")
+ # Phase 5: Graph aus ai_prompts.graph_data
+ graph_data = prompt.get('graph_data')
+ if not graph_data:
+ raise HTTPException(400, "Workflow-Prompt fehlt 'graph_data' Feld")
- # Execute workflow
+ # Execute workflow (mit graph_data statt workflow_id)
result = await execute_workflow(
- workflow_id=workflow_id,
+ graph_data=graph_data, # NEU: Direkt graph_data übergeben
profile_id=variables.get('profile_id', 'unknown'), # From context
variables=variables,
openrouter_call_func=openrouter_call_func,
diff --git a/backend/workflow_executor.py b/backend/workflow_executor.py
index 511a142..262688a 100644
--- a/backend/workflow_executor.py
+++ b/backend/workflow_executor.py
@@ -35,10 +35,11 @@ logger = logging.getLogger(__name__)
async def execute_workflow(
- workflow_id: str,
- profile_id: str,
- variables: Dict[str, Any],
- openrouter_call_func, # Callback für LLM-Calls: async (prompt, model) -> str
+ workflow_id: Optional[str] = None,
+ graph_data: Optional[Dict] = None, # Phase 5: Direkt von ai_prompts.graph_data
+ profile_id: str = None,
+ variables: Dict[str, Any] = None,
+ openrouter_call_func = None, # Callback für LLM-Calls: async (prompt, model) -> str
enable_debug: bool = False
) -> ExecutionResult:
"""
@@ -47,9 +48,11 @@ async def execute_workflow(
Phase 2: Linear execution in topological order.
Phase 3: Conditional branching basierend auf logic nodes.
Phase 4: Join nodes und path consolidation.
+ Phase 5: Unterstützt graph_data direkt (aus ai_prompts, nicht workflow_definitions)
Args:
- workflow_id: UUID des Workflows
+ workflow_id: UUID des Workflows (legacy, für workflow_definitions Tabelle)
+ graph_data: Workflow-Graph als Dict (NEU, für ai_prompts.graph_data)
profile_id: UUID des Profils
variables: Platzhalter-Werte (z.B. {"name": "Lars", ...})
openrouter_call_func: async (prompt, model) -> str
@@ -58,36 +61,41 @@ async def execute_workflow(
Returns:
ExecutionResult mit allen node_states
- Beispiel:
+ Beispiel (Phase 5):
>>> result = await execute_workflow(
- ... workflow_id="test-workflow",
+ ... graph_data={"nodes": [...], "edges": [...]},
... profile_id="test-profile",
... variables={"name": "Lars"},
... openrouter_call_func=my_llm_func
... )
- >>> result.status
- 'completed'
- >>> len(result.node_states)
- 3
"""
execution_id = str(uuid.uuid4())
started_at = datetime.utcnow().isoformat()
- logger.info(f"Starting workflow execution: {execution_id} (workflow: {workflow_id})")
+ logger.info(f"Starting workflow execution: {execution_id}")
try:
# 1. Lade Workflow-Definition
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute(
- "SELECT graph FROM workflow_definitions WHERE id = %s AND active = true",
- (workflow_id,)
- )
- row = cur.fetchone()
- if not row:
- raise ValueError(f"Workflow not found: {workflow_id}")
+ if graph_data:
+ # Phase 5: Graph direkt aus ai_prompts.graph_data
+ graph_json = json.dumps(graph_data)
+ logger.debug(f"Using provided graph_data")
+ elif workflow_id:
+ # Phase 0-4: Graph aus workflow_definitions Tabelle (legacy)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ "SELECT graph FROM workflow_definitions WHERE id = %s AND active = true",
+ (workflow_id,)
+ )
+ row = cur.fetchone()
+ if not row:
+ raise ValueError(f"Workflow not found: {workflow_id}")
- graph_json = row['graph']
+ graph_json = row['graph']
+ logger.debug(f"Loaded graph from workflow_definitions: {workflow_id}")
+ else:
+ raise ValueError("Entweder workflow_id oder graph_data muss übergeben werden")
# 2. Parse Graph
graph = parse_workflow_graph(graph_json)
diff --git a/docs/issues/PHASE_PLAN_RESPONSIVE_UI.md b/docs/issues/PHASE_PLAN_RESPONSIVE_UI.md
new file mode 100644
index 0000000..1e60f56
--- /dev/null
+++ b/docs/issues/PHASE_PLAN_RESPONSIVE_UI.md
@@ -0,0 +1,264 @@
+# Phasenplan: Responsive UI (Desktop Sidebar + Mobile/PWA)
+
+> **Gitea:** [#30 – Responsive UI](http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/30)
+> **Spec:** `.claude/docs/functional/RESPONSIVE_UI.md`
+> **Breakpoint:** `<1024px` = Mobile (Bottom-Nav, bestehendes Verhalten), `≥1024px` = Desktop (Sidebar 220px)
+> **Letzte Plan-Aktualisierung:** 2026-04-04
+
+---
+
+## Fortschritt (kurz)
+
+| Phase | Titel | Status | Datum / Notiz |
+|-------|--------|--------|----------------|
+| P0 | Vorbereitung & Baseline | ☐ pending | |
+| P1 | App-Shell: Sidebar + Breakpoint + gemeinsame Navigation | ☐ pending | |
+| P2 | Globales Layout & Content-Bereich (CSS) | ☐ pending | |
+| P3 | Dashboard (Desktop-Grid) | ☐ pending | |
+| P4 | Verlauf (Tabs links / Content rechts) | ☐ pending | |
+| P5 | Analyse (Prompts links / Ergebnis rechts) | ☐ pending | |
+| P6 | Erfassung / Capture & Formularseiten | ☐ pending | |
+| P7 | Admin & restliche Vollbreiten-Seiten | ☐ pending | |
+| P8 | Abschluss, Regression, Spec-Pflege | ☐ pending | |
+
+**Status-Legende:** `☐ pending` · `◐ in Arbeit` · `☑ erledigt` · `⏸ blockiert`
+
+---
+
+## Testumgebung (für alle Phasen)
+
+| ID | Umgebung | Verwendung |
+|----|-----------|------------|
+| T-H1 | Chromium Desktop, Fenster **≥1280px** | Desktop-Layout, Sidebar |
+| T-H2 | Chromium, DevTools **375×812** (o. ä.) | Mobile-Layout, Bottom-Nav |
+| T-H3 | Chromium, Breite **1023px** vs **1024px** | Breakpoint-Grenze |
+| T-H4 | **iPhone** (Safari), installierte **PWA** | Regression Mobile/PWA |
+| T-H5 | optional: iPad quer (**≥1024px**) | Desktop-Sidebar auf Tablet quer |
+
+**Allgemein:** Kein Pflicht-E2E-Framework im Repo; Abnahme primär **manuell** nach Checklisten unten. Optional später: Playwright-Smoke (`viewport`-Wechsel).
+
+---
+
+## Phase P0 – Vorbereitung & Baseline
+
+### Ziel
+Risikoarm starten: Ist-Stand dokumentieren, Spec bereinigen, keine funktionale UI-Änderung.
+
+### Aufgaben
+- [ ] Bekannte Artefakte am Ende von `.claude/docs/functional/RESPONSIVE_UI.md` entfernen (Zeilen `EOF` / `echo …`), falls noch vorhanden.
+- [ ] Kurz notieren: aktuelle `app-shell`/`max-width:600px` in `app.css` ist die Mobile-Baseline (siehe Code-Review).
+
+### Abnahmekriterien
+- Spec-Datei endet mit den „Offenen Fragen“; keine Shell-Zeilenduplikate aus Fremdkopien.
+- Plan-Datei (dieses Dokument) ist im Repo und verlinkt (optional in Gitea #30 kommentieren).
+
+### Tests (P0)
+| Test | Schritt | Erwartung |
+|------|---------|-----------|
+| P0-T1 | `RESPONSIVE_UI.md` öffnen | Letzte sinnvolle Zeile ist Frage 4 oder Abschnittsende; kein `echo`/`EOF` |
+| P0-T2 | `npm run build` im `frontend/` | Exit 0 |
+
+---
+
+## Phase P1 – App-Shell: Sidebar + Breakpoint + gemeinsame Navigation
+
+### Ziel
+Desktop: feste **Sidebar 220px** links gemäß Spec; Mobile: **unverändert** Bottom-Nav. **Eine** Navigationsdefinition (Routes/Labels/Icons) für beide Darstellungen. Öffentliche Auth-Routen (`/register`, `/verify`, …) **nicht** in Sidebar/Bottom-Nav einbinden (wie heute).
+
+### Aufgaben (technisch orientierend)
+- [ ] Nav-Items aus `App.jsx` (`Nav`-Komponente) in **eine** exportierbare Struktur (z. B. `navItems` Array + `admin`-Eintrag nur bei `role === 'admin'`), von **Desktop-Sidebar** und **Mobile `Nav`** gemeinsam genutzt.
+- [ ] Neue Komponente z. B. `DesktopSidebar.jsx` (oder `AppSidebar.jsx`): Logo/Branding, gleiche Links wie Bottom-Nav, unten Nutzerbereich (Avatar, Name/Tier laut Spec – Daten aus `useProfile`/`useAuth` wo möglich).
+- [ ] `AppShell`: bei `≥1024px` Sidebar sichtbar, **Bottom-Nav ausgeblendet**; bei `<1024px` **umgekehrt**.
+- [ ] Aktiver Route-State: `NavLink`/`active` in Sidebar **gleiche Semantik** wie Bottom-Nav (`end` bei `/` beachten).
+- [ ] **Kein** `resize`-Listener pflichtig; primär **CSS** (`@media (min-width: 1024px)`) für Sichtbarkeit; falls nötig nur für Edge-Cases.
+
+### Abnahmekriterien
+- Unter **1024px**: UI wirkt **wie vor P1** (Bottom-Nav sichtbar, Sidebar unsichtbar); PWA-Start auf iPhone unverändert nutzbar.
+- Ab **1024px**: Sidebar sichtbar, fixed links, ~220px; Bottom-Nav **nicht** sichtbar.
+- Admin-Link nur für Admins in **beiden** Navs.
+- Abmelden erreichbar (Spec: im Sidebar-Footer; falls Mobile weiter nur im Header: ** dokumentieren als Abweichung** oder gleich ziehen).
+
+### Tests (P1)
+| Test | Schritt | Erwartung |
+|------|---------|-----------|
+| P1-T1 | T-H2: einloggen, alle 5 Haupt-Routen antippen | Bottom-Nav aktiv, kein Layout-Bruch |
+| P1-T2 | T-H1: gleiche Routen | Nur Sidebar-Navigation sichtbar, korrekte Aktiv-Markierung |
+| P1-T3 | T-H3: Breite 1023→1024 wechseln | Sidebar erscheint / Bottom verschwindet ohne Reload; kein „Flackern“-Loop |
+| P1-T4 | T-H4: PWA | Login, Hauptflows kurz (Dashboard, Erfassung) – Bottom-Nav ok |
+| P1-T5 | Admin-User T-H1 | Admin-Eintrag in Sidebar; Nicht-Admin ohne Admin |
+
+---
+
+## Phase P2 – Globales Layout & Content-Bereich
+
+### Ziel
+Desktop-**Content** rechts von der Sidebar: Spec **margin-left 220px**, Padding **24px 32px**, **max-width 1200px** zentriert im verbleibenden Raum. Mobile: beibehaltene Abstände (**16px**, **80px** bottom für Nav).
+
+### Aufgaben
+- [ ] `app.css`: `.app-shell` **nicht** mehr global `max-width: 600px` für Desktop; Mobile beibehalten oder per Media Query trennen.
+- [ ] `.app-main` Padding/Bottom: Mobile `calc(nav + 16px)`; Desktop **ohne** Bottom-Nav-Padding (oder nur safe-area falls nötig).
+- [ ] Optional Desktop: **Top-Header** (`app-header`) redundant mit Sidebar-Branding – **vereinheitlichen** (z. B. Header auf Desktop ausblenden oder auf kompakte Toolbar reduzieren), damit keine doppelte Logo-Zeile.
+
+### Abnahmekriterien
+- Desktop: Content nutzt **sichtbar mehr** horizontalen Raum als heute (max 1200px Inhalt, zentriert).
+- Mobile: **kein** zusätzliches horizontales Scrollen der Gesamtseite durch P1/P2.
+- Keine Überlappung Sidebar/Content.
+
+### Tests (P2)
+| Test | Schritt | Erwartung |
+|------|---------|-----------|
+| P2-T1 | T-H1: lange Seite scrollen | Nur Main-Area scrollt; Sidebar bleibt sichtbar (fixed) |
+| P2-T2 | T-H2 | Weiterhin nutzbarer unterer Rand für Bottom-Nav |
+| P2-T3 | T-H1: sehr breiter Monitor | Content max ~1200px, optisch zentriert rechts von Sidebar |
+
+---
+
+## Phase P3 – Dashboard (Desktop-Grid)
+
+### Ziel
+Spec **§5.1**: Desktop mehrspaltige Karten (z. B. 4 Spalten für Kennzahlen-Zeile); Mobile Karten untereinander.
+
+### Aufgaben
+- [ ] `Dashboard.jsx`: Layout in **CSS Grid** o. ä. mit `@media (min-width: 1024px)` für 4-Spalten-Zeile(n) laut Wireframe; Mobile unveränderte Reihenfolge.
+- [ ] Charts/„volle Breite“-Blöcke unter den Karten laut Spec.
+
+### Abnahmekriterien
+- Mobile Dashboard **visuell vergleichbar** zum Stand vor P3 (keine funktionalen Regressionen).
+- Desktop: klar erkennbares **Grid**; keine übermäßigen Lücken bei typischer Viewport-Höhe.
+
+### Tests (P3)
+| Test | Schritt | Erwartung |
+|------|---------|-----------|
+| P3-T1 | T-H2 Dashboard | Karten untereinander, lesbar |
+| P3-T2 | T-H1 Dashboard | Mehrspaltigkeit wie Spec; kein horizontaler Overflow |
+| P3-T3 | Daten mit/ohne Werte | Keine Layout-Crashes (leere Zustände) |
+
+---
+
+## Phase P4 – Verlauf (`History.jsx`)
+
+### Ziel
+Spec **§5.2**: Desktop **Tabs vertikal links**, Chart/Tabelle **rechts** (volle restliche Breite); Mobile Tabs oben wie heute.
+
+### Aufgaben
+- [ ] Tab-Steuerung refaktorieren oder per CSS **ohne** doppelte State-Logik.
+- [ ] Desktop: flex/grid `240–280px` Tab-Spalte + flex 1 Chart-Bereich (feinjustierbar).
+
+### Abnahmekriterien
+- Alle bisherigen Verlauf-**Tabs** funktionieren (Gewicht, KF, Umfänge, … – wie im aktuellen Code).
+- Mobile UX **unverändert** vom Nutzergefühl her (Tabs oben).
+- Desktop: klar **zwei Spalten**; Chart nutzt rechte Fläche.
+
+### Tests (P4)
+| Test | Schritt | Erwartung |
+|------|---------|-----------|
+| P4-T1 | T-H2: Tab wechseln | Wie bisher |
+| P4-T2 | T-H1: jeder Tab | Linke Liste + rechter Chart-Bereich sichtbar |
+| P4-T3 | Langes Tab-Label / viele Tabs | Kein Layout-Bruch (Scroll in Tab-Spalte falls nötig) |
+
+---
+
+## Phase P5 – Analyse (`Analysis.jsx`)
+
+### Ziel
+Spec **§5.3**: Desktop **Prompt-Liste links (~300px)**, Ergebnis **rechts**; Mobile untereinander.
+
+### Aufgaben
+- [ ] Layout splitten; Pipeline/Prompt-Bereiche so umbauen, dass Lesbarkeit und Scroll-Verhalten stimmen.
+
+### Abnahmekriterien
+- KI-Ausführung, Platzhalter-Tabelle, Experten-Modus: weiter bedienbar.
+- Desktop: Ergebnisbereich hat **deutlich mehr** Breite als Mobile.
+
+### Tests (P5)
+| Test | Schritt | Erwartung |
+|------|---------|-----------|
+| P5-T1 | T-H2: Analyse ausführen | Flow wie bisher |
+| P5-T2 | T-H1: langes Ergebnis | Rechte Spalte scrollbar, linke Prompt-Liste fix oder eigen-scroll |
+| P5-T3 | Kleines Fenster knapp unter 1024px | Kein „mittendrin“ kaputtes Layout |
+
+---
+
+## Phase P6 – Erfassung / Capture & Formularseiten
+
+### Ziel
+Spec **§5.4**: Desktop Formulare **zentriert**, **max-width ~600px** im Content-Bereich; Mobile volle Breite wie heute.
+
+### Aufgaben
+- [ ] `CaptureHub` und relevante Seiten (`WeightScreen`, …) unter Desktop-Wrapper; wo sinnvoll **gemeinsame** Klasse `.capture-form-desktop` in `app.css`.
+
+### Abnahmekriterien
+- Mobile: keine Verschlechterung der Eingabe.
+- Desktop: Formulare nicht „volle 1200px“, sondern **lesbar begrenzt**.
+
+### Tests (P6)
+| Test | Schritt | Erwartung |
+|------|---------|-----------|
+| P6-T1 | T-H2: Gewicht erfassen | Vollbreite ok |
+| P6-T2 | T-H1: gleiche Seite | Schmale, zentrierte Spalte |
+| P6-T3 | Capture-Hub Kacheln | Umbruch auf Desktop ordentlich |
+
+---
+
+## Phase P7 – Admin & übrige Seiten
+
+### Ziel
+Spec **§5.5**: Admin-Tabellen nutzen auf Desktop **mehr Breite**; Mobile weiterhin horizontales Scrollen wo nötig.
+
+### Aufgaben
+- [ ] `Admin*Page.jsx` und ähnliche Tabellen-Seiten: unnötige `max-width`-Erbe von Mobile entfernen; **nicht** jede Admin-Seite einzeln zerstören – priorisiert häufig genutzte.
+
+### Abnahmekriterien
+- Keine regressiven Auth-Schutz-Änderungen.
+- Desktop: **mehr sichtbare Spalten** bei typischen Admin-Tabellen.
+
+### Tests (P7)
+| Test | Schritt | Erwartung |
+|------|---------|-----------|
+| P7-T1 | T-H1: z. B. Admin Training Types | Tabelle nutzt Breite; horizontales Scrollen nur bei Bedarf |
+| P7-T2 | T-H2: gleiche Seite | Weiter bedienbar mit Scroll |
+
+---
+
+## Phase P8 – Abschluss, Regression, Dokumentation
+
+### Ziel
+Release-tauglich machen; Spec und Issue aktualisieren.
+
+### Aufgaben
+- [ ] Alle Phasen P1–P7 in Fortschrittstabelle auf ☑ setzen.
+- [ ] Vollständiger **Regression-Pass** (Routenliste aus `App.jsx`).
+- [ ] Gitea #30: Abschlusskommentar mit **Screenshots** (optional) + Hinweis auf diesen Plan.
+- [ ] `REVIEW_OPEN_ISSUES_2026-04-04.md` oder Nachfolger: #30 auf DONE setzen, wenn aus eurer Sicht erledigt.
+
+### Abnahmekriterien (Gesamt gem. #30 / Spec)
+- [ ] Desktop **≥1024px**: Sidebar links, Bottom-Nav aus.
+- [ ] Mobile **<1024px**: Bottom-Nav unten, Sidebar aus.
+- [ ] Aktive Route in beiden Navs korrekt.
+- [ ] Dashboard / Verlauf / Analyse / Erfassung / Admin gemäß Spec umgesetzt.
+- [ ] Resize ohne JavaScript-Zwang stabil (CSS-first).
+- [ ] **PWA iPhone** weiterhin funktionsfähig (Kernflows).
+
+### Tests (P8 – Smoke-Checkliste)
+| Route | T-H2 | T-H1 |
+|-------|------|------|
+| `/` | ☐ | ☐ |
+| `/capture` | ☐ | ☐ |
+| `/history` | ☐ | ☐ |
+| `/analysis` | ☐ | ☐ |
+| `/settings` | ☐ | ☐ |
+| 1× Admin (falls verfügbar) | ☐ | ☐ |
+| T-H4 PWA kurz | ☐ | n/a |
+
+---
+
+## Parallelität (Canvas / Workflow)
+
+- Während **P1–P2** möglichst **keine** parallelenden Änderungen an `App.jsx` / globalem `app.css` ohne Absprache.
+- Neue **Workflow-/Canvas-Routen**: nur innerhalb von ``; Sidebar- und Breakpoint-**Kontrakt** einhalten (`margin-left`, keine zweite globale Spalte).
+
+---
+
+## Planpflege
+
+Änderungen an Phasen, Kriterien oder Status: **dieses File** anpassen und kurz „Letzte Plan-Aktualisierung“ oben datieren. Bei größeren Scope-Änderungen Gitea #30 kommentieren.
diff --git a/docs/issues/REVIEW_OPEN_ISSUES_2026-04-04.md b/docs/issues/REVIEW_OPEN_ISSUES_2026-04-04.md
index 7232f69..9f6e98b 100644
--- a/docs/issues/REVIEW_OPEN_ISSUES_2026-04-04.md
+++ b/docs/issues/REVIEW_OPEN_ISSUES_2026-04-04.md
@@ -25,8 +25,8 @@
| 27 | Korrelationen & Insights | TEILWEISE | C-Charts + offene Data-Layer-TODOs |
| 29 | Abilities-Matrix UI | TEILWEISE | Admin/ProfileBuilder, UX offen |
| 30 | Responsive UI Sidebar | OFFEN | Weiterhin Bottom-Nav-fokussiert |
-| 32 | version.py + `/api/version` | OFFEN | `version.py` ja, dedizierter Endpoint nein |
-| 33 | main.py Hardcoded Version | OFFEN | FastAPI `3.0.0`, Root `v9c-dev` |
+| 32 | Version-System (inkl. ehem. #33) | OFFEN | Gitea: Body/Titel 2026-04-04 aktualisiert; Runner/Build-Git bewusst später |
+| 33 | — | GESCHLOSSEN | In #32 konsolidiert (superseded) |
| 34 | External Volumes Doku | PRÜFEN | Gegen Compose abgleichen |
| 35 | `subscriptions` Tabelle | PRÜFEN | Schema prüfen |
| 36 | BUG Trainingstyp ISE | PRÜFEN | Logs nötig |
@@ -117,23 +117,19 @@
**Code-Stand:** Weiterhin stark **Mobile-first** (z. B. `bottom-nav` in `App.jsx`); keine ausgebaute Desktop-Sidebar wie im klassischen Admin-Dashboard.
+**Umsetzungsplan:** `docs/issues/PHASE_PLAN_RESPONSIVE_UI.md` (Phasen P0–P8, Abnahmekriterien & Tests, Fortschrittstabelle).
+
**Vorschlag:** `OFFEN`.
---
-### #32 – Version-System (`version.py` + `/api/version`)
+### #32 – Version-System (inkl. ehem. #33)
-**Code-Stand:** `backend/version.py` existiert mit `APP_VERSION`. **`GET /api/version`** im Backend **nicht** gefunden (Suche nach Route); Root liefert u. a. `"version": "v9c-dev"`.
+**Gitea (2026-04-04):** #33 geschlossen; Inhalt in **#32** zusammengeführt (ein Epic: `/api/version`, `version.js`, Settings, `main.py`-Konsistenz). Automatische Git-/Build-Identität über den Runner: **zurückgestellt**, geplant als **separates Issue** nach der Basis.
-**Vorschlag:** `OFFEN` für #32 – `/api/version` implementieren oder Issue anpassen („nur version.py ohne Endpoint“).
+**Code-Stand:** `backend/version.py` vorhanden; **`GET /api/version`** fehlt; `main.py`: Root `v9c-dev`, `FastAPI(..., version="3.0.0")`.
----
-
-### #33 – main.py hardcoded Version entfernen
-
-**Code-Stand:** `main.py`: `FastAPI(..., version="3.0.0")`; Root-JSON noch `v9c-dev`.
-
-**Vorschlag:** `OFFEN` – auf `version.py` vereinheitlichen (inkl. FastAPI-`version`-Feld und Health-Payload).
+**Vorschlag:** `OFFEN` – Umsetzung laut aktualisiertem Gitea-Issue #32.
---
@@ -254,7 +250,7 @@
- [ ] Duplikate schließen und verlinken (#42/#43, #54/#55, #56–#58).
- [ ] „DONE?“-Issues manuell testen (`#25`, `#38`, `#40`).
- [ ] `#37` umsetzen oder Kommentar „noch offen“ bestätigen.
-- [ ] `#32`–`#33` Versionierung planen (ein gemeinsames Mini-Epic).
+- [x] `#32`–`#33` in Gitea zusammengeführt (#33 geschlossen); Umsetzung weiter über #32.
- [ ] Kommentare aus diesem Dokument kopieren/anpassen.
- [ ] Optional: Labels in Gitea setzen (`duplicate`, `blocked`, `needs-retest`).
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 75c2000..6c0c00d 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -15,6 +15,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1",
+ "reactflow": "^11.11.4",
"recharts": "^2.12.7"
},
"devDependencies": {
@@ -2059,6 +2060,108 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@reactflow/background": {
+ "version": "11.3.14",
+ "resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz",
+ "integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==",
+ "license": "MIT",
+ "dependencies": {
+ "@reactflow/core": "11.11.4",
+ "classcat": "^5.0.3",
+ "zustand": "^4.4.1"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
+ "node_modules/@reactflow/controls": {
+ "version": "11.2.14",
+ "resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.14.tgz",
+ "integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==",
+ "license": "MIT",
+ "dependencies": {
+ "@reactflow/core": "11.11.4",
+ "classcat": "^5.0.3",
+ "zustand": "^4.4.1"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
+ "node_modules/@reactflow/core": {
+ "version": "11.11.4",
+ "resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.11.4.tgz",
+ "integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3": "^7.4.0",
+ "@types/d3-drag": "^3.0.1",
+ "@types/d3-selection": "^3.0.3",
+ "@types/d3-zoom": "^3.0.1",
+ "classcat": "^5.0.3",
+ "d3-drag": "^3.0.0",
+ "d3-selection": "^3.0.0",
+ "d3-zoom": "^3.0.0",
+ "zustand": "^4.4.1"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
+ "node_modules/@reactflow/minimap": {
+ "version": "11.7.14",
+ "resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.14.tgz",
+ "integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@reactflow/core": "11.11.4",
+ "@types/d3-selection": "^3.0.3",
+ "@types/d3-zoom": "^3.0.1",
+ "classcat": "^5.0.3",
+ "d3-selection": "^3.0.0",
+ "d3-zoom": "^3.0.0",
+ "zustand": "^4.4.1"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
+ "node_modules/@reactflow/node-resizer": {
+ "version": "2.2.14",
+ "resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz",
+ "integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==",
+ "license": "MIT",
+ "dependencies": {
+ "@reactflow/core": "11.11.4",
+ "classcat": "^5.0.4",
+ "d3-drag": "^3.0.0",
+ "d3-selection": "^3.0.0",
+ "zustand": "^4.4.1"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
+ "node_modules/@reactflow/node-toolbar": {
+ "version": "1.3.14",
+ "resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz",
+ "integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@reactflow/core": "11.11.4",
+ "classcat": "^5.0.3",
+ "zustand": "^4.4.1"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
"node_modules/@remix-run/router": {
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
@@ -2554,24 +2657,159 @@
"@babel/types": "^7.28.2"
}
},
+ "node_modules/@types/d3": {
+ "version": "7.4.3",
+ "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
+ "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-array": "*",
+ "@types/d3-axis": "*",
+ "@types/d3-brush": "*",
+ "@types/d3-chord": "*",
+ "@types/d3-color": "*",
+ "@types/d3-contour": "*",
+ "@types/d3-delaunay": "*",
+ "@types/d3-dispatch": "*",
+ "@types/d3-drag": "*",
+ "@types/d3-dsv": "*",
+ "@types/d3-ease": "*",
+ "@types/d3-fetch": "*",
+ "@types/d3-force": "*",
+ "@types/d3-format": "*",
+ "@types/d3-geo": "*",
+ "@types/d3-hierarchy": "*",
+ "@types/d3-interpolate": "*",
+ "@types/d3-path": "*",
+ "@types/d3-polygon": "*",
+ "@types/d3-quadtree": "*",
+ "@types/d3-random": "*",
+ "@types/d3-scale": "*",
+ "@types/d3-scale-chromatic": "*",
+ "@types/d3-selection": "*",
+ "@types/d3-shape": "*",
+ "@types/d3-time": "*",
+ "@types/d3-time-format": "*",
+ "@types/d3-timer": "*",
+ "@types/d3-transition": "*",
+ "@types/d3-zoom": "*"
+ }
+ },
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
+ "node_modules/@types/d3-axis": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
+ "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-brush": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
+ "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-chord": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
+ "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
+ "license": "MIT"
+ },
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
+ "node_modules/@types/d3-contour": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
+ "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-array": "*",
+ "@types/geojson": "*"
+ }
+ },
+ "node_modules/@types/d3-delaunay": {
+ "version": "6.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
+ "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-dispatch": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
+ "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-drag": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
+ "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-dsv": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
+ "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
+ "license": "MIT"
+ },
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
+ "node_modules/@types/d3-fetch": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
+ "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-dsv": "*"
+ }
+ },
+ "node_modules/@types/d3-force": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
+ "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-format": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
+ "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-geo": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
+ "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/geojson": "*"
+ }
+ },
+ "node_modules/@types/d3-hierarchy": {
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
+ "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
+ "license": "MIT"
+ },
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
@@ -2587,6 +2825,24 @@
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
+ "node_modules/@types/d3-polygon": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
+ "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-quadtree": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
+ "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-random": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
+ "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
+ "license": "MIT"
+ },
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
@@ -2596,6 +2852,18 @@
"@types/d3-time": "*"
}
},
+ "node_modules/@types/d3-scale-chromatic": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
+ "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-selection": {
+ "version": "3.0.11",
+ "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
+ "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
+ "license": "MIT"
+ },
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
@@ -2611,12 +2879,37 @@
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
+ "node_modules/@types/d3-time-format": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
+ "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
+ "license": "MIT"
+ },
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
+ "node_modules/@types/d3-transition": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
+ "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-zoom": {
+ "version": "3.0.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
+ "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-interpolate": "*",
+ "@types/d3-selection": "*"
+ }
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -2624,6 +2917,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/geojson": {
+ "version": "7946.0.16",
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+ "license": "MIT"
+ },
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
@@ -3024,6 +3323,12 @@
"node": ">=10.0.0"
}
},
+ "node_modules/classcat": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
+ "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
+ "license": "MIT"
+ },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -3145,6 +3450,28 @@
"node": ">=12"
}
},
+ "node_modules/d3-dispatch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+ "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-drag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
+ "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-selection": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
@@ -3200,6 +3527,16 @@
"node": ">=12"
}
},
+ "node_modules/d3-selection": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
+ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
+ "license": "ISC",
+ "peer": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
@@ -3245,6 +3582,41 @@
"node": ">=12"
}
},
+ "node_modules/d3-transition": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
+ "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-dispatch": "1 - 3",
+ "d3-ease": "1 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "peerDependencies": {
+ "d3-selection": "2 - 3"
+ }
+ },
+ "node_modules/d3-zoom": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
+ "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "2 - 3",
+ "d3-transition": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/data-view-buffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@@ -5161,6 +5533,24 @@
"react-dom": ">=16.6.0"
}
},
+ "node_modules/reactflow": {
+ "version": "11.11.4",
+ "resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz",
+ "integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==",
+ "license": "MIT",
+ "dependencies": {
+ "@reactflow/background": "11.3.14",
+ "@reactflow/controls": "11.2.14",
+ "@reactflow/core": "11.11.4",
+ "@reactflow/minimap": "11.7.14",
+ "@reactflow/node-resizer": "2.2.14",
+ "@reactflow/node-toolbar": "1.3.14"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
"node_modules/recharts": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
@@ -6187,6 +6577,15 @@
"browserslist": ">= 4.21.0"
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
@@ -6761,6 +7160,34 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
+ },
+ "node_modules/zustand": {
+ "version": "4.5.7",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
+ "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
+ "license": "MIT",
+ "dependencies": {
+ "use-sync-external-store": "^1.2.2"
+ },
+ "engines": {
+ "node": ">=12.7.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=16.8",
+ "immer": ">=9.0.6",
+ "react": ">=16.8"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/frontend/package.json b/frontend/package.json
index b7d31bf..fd1d304 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -9,14 +9,15 @@
"preview": "vite preview"
},
"dependencies": {
+ "dayjs": "^1.11.11",
+ "jspdf": "^2.5.1",
+ "jspdf-autotable": "^3.8.2",
+ "lucide-react": "^0.383.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1",
- "recharts": "^2.12.7",
- "jspdf": "^2.5.1",
- "jspdf-autotable": "^3.8.2",
- "dayjs": "^1.11.11",
- "lucide-react": "^0.383.0"
+ "reactflow": "^11.11.4",
+ "recharts": "^2.12.7"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.0",
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index c75f6cf..5175e20 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -39,6 +39,7 @@ import RestDaysPage from './pages/RestDaysPage'
import VitalsPage from './pages/VitalsPage'
import GoalsPage from './pages/GoalsPage'
import CustomGoalsPage from './pages/CustomGoalsPage'
+import WorkflowEditorPage from './pages/WorkflowEditorPage'
import './app.css'
function Nav() {
@@ -194,6 +195,7 @@ function AppShell() {
}/>
}/>
}/>
+ }/>
}/>
diff --git a/frontend/src/components/workflow/WorkflowCanvas.jsx b/frontend/src/components/workflow/WorkflowCanvas.jsx
new file mode 100644
index 0000000..91ca62d
--- /dev/null
+++ b/frontend/src/components/workflow/WorkflowCanvas.jsx
@@ -0,0 +1,70 @@
+import ReactFlow, { Background, Controls, MiniMap } from 'reactflow'
+import 'reactflow/dist/style.css'
+
+/**
+ * WorkflowCanvas - React Flow Wrapper Component
+ *
+ * Kapselt React Flow Setup (Background, Controls, MiniMap).
+ * Separation of Concerns: Canvas-Logik getrennt von Editor-Orchestrierung.
+ *
+ * Props:
+ * - nodes: Array of React Flow nodes
+ * - edges: Array of React Flow edges
+ * - nodeTypes: Object mapping node type to component
+ * - onNodesChange: Handler for node changes (drag, delete, etc.)
+ * - onEdgesChange: Handler for edge changes
+ * - onConnect: Handler for new edge connections
+ * - onNodeClick: Handler for node selection
+ */
+export function WorkflowCanvas({
+ nodes,
+ edges,
+ nodeTypes,
+ onNodesChange,
+ onEdgesChange,
+ onConnect,
+ onNodeClick
+}) {
+ return (
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/workflow/nodes/AnalysisNode.jsx b/frontend/src/components/workflow/nodes/AnalysisNode.jsx
new file mode 100644
index 0000000..550f2f0
--- /dev/null
+++ b/frontend/src/components/workflow/nodes/AnalysisNode.jsx
@@ -0,0 +1,51 @@
+import { Handle, Position } from 'reactflow'
+
+/**
+ * AnalysisNode - KI-Prompt Knoten
+ *
+ * Properties:
+ * - data.label: Node-Label
+ * - data.prompt_id: ID des referenzierten Basis-Prompts
+ * - data.prompt_name: Name des Prompts (optional, für Display)
+ * - data.questions: Array von Question Augmentations
+ * - selected: Boolean
+ */
+export function AnalysisNode({ data, selected }) {
+ const hasQuestions = data.questions?.length > 0
+ const promptName = data.prompt_name || (data.prompt_id ? `Prompt #${data.prompt_id}` : 'Kein Prompt')
+ const questionCount = data.questions?.length || 0
+
+ return (
+
+
+
🤖
+
{data.label || 'Analyse'}
+
+
+
+
+ {promptName}
+
+
+ {hasQuestions && (
+
+ 📋 {questionCount} {questionCount === 1 ? 'Frage' : 'Fragen'}
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/workflow/nodes/EndNode.jsx b/frontend/src/components/workflow/nodes/EndNode.jsx
new file mode 100644
index 0000000..2e03bcf
--- /dev/null
+++ b/frontend/src/components/workflow/nodes/EndNode.jsx
@@ -0,0 +1,25 @@
+import { Handle, Position } from 'reactflow'
+
+/**
+ * EndNode - Workflow Austritt
+ *
+ * Properties:
+ * - data.label: Node-Label (default: "Ende")
+ * - selected: Boolean (Node ist ausgewählt)
+ */
+export function EndNode({ data, selected }) {
+ return (
+
+
🏁
+
{data.label || 'Ende'}
+
+ {/* Nur Target Handle (kein Source, da Endpunkt) */}
+
+
+ )
+}
diff --git a/frontend/src/components/workflow/nodes/JoinNode.jsx b/frontend/src/components/workflow/nodes/JoinNode.jsx
new file mode 100644
index 0000000..820dac0
--- /dev/null
+++ b/frontend/src/components/workflow/nodes/JoinNode.jsx
@@ -0,0 +1,74 @@
+import { Handle, Position } from 'reactflow'
+
+/**
+ * JoinNode - Pfad-Konsolidierung
+ *
+ * Properties:
+ * - data.label: Node-Label
+ * - data.join_strategy: 'wait_all' | 'wait_any' | 'best_effort'
+ * - data.skip_handling: 'ignore_skipped' | 'use_placeholder' | 'require_minimum'
+ * - selected: Boolean
+ */
+export function JoinNode({ data, selected }) {
+ const strategy = data.join_strategy || 'wait_all'
+ const skipHandling = data.skip_handling || 'ignore_skipped'
+
+ // Strategy Display Names
+ const strategyLabels = {
+ 'wait_all': 'Alle warten',
+ 'wait_any': 'Beliebig',
+ 'best_effort': 'Best Effort'
+ }
+
+ const skipLabels = {
+ 'ignore_skipped': 'Ignorieren',
+ 'use_placeholder': 'Platzhalter',
+ 'require_minimum': 'Minimum'
+ }
+
+ return (
+
+
+
🔀
+
{data.label || 'Join'}
+
+
+
+
+ Strategie: {strategyLabels[strategy] || strategy}
+
+
+ Skip: {skipLabels[skipHandling] || skipHandling}
+
+
+
+ {/* Mehrere Target Handles für eingehende Pfade */}
+
+
+
+
+ {/* Ein Source Handle für konsolidierten Ausgang */}
+
+
+ )
+}
diff --git a/frontend/src/components/workflow/nodes/LogicNode.jsx b/frontend/src/components/workflow/nodes/LogicNode.jsx
new file mode 100644
index 0000000..617ffda
--- /dev/null
+++ b/frontend/src/components/workflow/nodes/LogicNode.jsx
@@ -0,0 +1,68 @@
+import { Handle, Position } from 'reactflow'
+
+/**
+ * LogicNode - Bedingungs-Knoten (If-Then-Else)
+ *
+ * Properties:
+ * - data.label: Node-Label
+ * - data.condition: Logic Expression Object
+ * - selected: Boolean
+ */
+export function LogicNode({ data, selected }) {
+ const hasCondition = !!data.condition && !!data.condition.operator
+
+ // Condition Summary (vereinfacht für Display)
+ const getConditionSummary = () => {
+ if (!hasCondition) return 'Keine Bedingung'
+
+ const op = data.condition.operator?.toUpperCase()
+ const operandCount = data.condition.operands?.length || 0
+
+ if (op === 'AND' || op === 'OR' || op === 'NOT') {
+ return `${op} (${operandCount} Bedingungen)`
+ }
+
+ // Simple condition (ref, operator, value)
+ if (data.condition.ref) {
+ return `${data.condition.ref} ${data.condition.operator} ...`
+ }
+
+ return 'Bedingung definiert'
+ }
+
+ return (
+
+
+
⚡
+
{data.label || 'Logik'}
+
+
+
+
+ {getConditionSummary()}
+
+
+
+
+
+ {/* Zwei Source Handles für True/False Pfade */}
+
+
+
+ )
+}
diff --git a/frontend/src/components/workflow/nodes/StartNode.jsx b/frontend/src/components/workflow/nodes/StartNode.jsx
new file mode 100644
index 0000000..327475c
--- /dev/null
+++ b/frontend/src/components/workflow/nodes/StartNode.jsx
@@ -0,0 +1,25 @@
+import { Handle, Position } from 'reactflow'
+
+/**
+ * StartNode - Workflow Einstiegspunkt
+ *
+ * Properties:
+ * - data.label: Node-Label (default: "Start")
+ * - selected: Boolean (Node ist ausgewählt)
+ */
+export function StartNode({ data, selected }) {
+ return (
+
+
🚀
+
{data.label || 'Start'}
+
+ {/* Nur Source Handle (kein Target, da Einstiegspunkt) */}
+
+
+ )
+}
diff --git a/frontend/src/components/workflow/panels/FallbackConfig.jsx b/frontend/src/components/workflow/panels/FallbackConfig.jsx
new file mode 100644
index 0000000..a1bf5b9
--- /dev/null
+++ b/frontend/src/components/workflow/panels/FallbackConfig.jsx
@@ -0,0 +1,70 @@
+/**
+ * FallbackConfig - Fallback-Strategie Konfiguration
+ *
+ * Props:
+ * - node: React Flow Node object
+ * - edges: Array of React Flow edges
+ * - onChange: (nodeId, updates) => void
+ */
+export function FallbackConfig({ node, edges, onChange }) {
+ const fallbackStrategy = node.data.fallback_strategy || 'conservative_skip'
+ const fallbackEdge = node.data.fallback_edge || null
+
+ // Outgoing Edges von diesem Node
+ const outgoingEdges = edges.filter(e => e.source === node.id)
+
+ const handleStrategyChange = (e) => {
+ const strategy = e.target.value
+ onChange(node.id, { fallback_strategy: strategy })
+
+ // Reset fallback_edge wenn strategy geändert wird
+ if (strategy === 'conservative_skip' || strategy === 'document_only') {
+ onChange(node.id, { fallback_edge: null })
+ }
+ }
+
+ const handleEdgeChange = (e) => {
+ onChange(node.id, { fallback_edge: e.target.value || null })
+ }
+
+ return (
+
+
Fallback-Strategie
+
+
Strategie bei unklaren Signalen
+
+ Konservativ überspringen
+ Standardpfad ausführen
+ Unsicherheits-Pfad
+ Nur dokumentieren
+
+
+
+ {fallbackStrategy === 'conservative_skip' && 'Bei Unklarheit: Pfad nicht routen.'}
+ {fallbackStrategy === 'default_path' && 'Bei Unklarheit: Definierter Standardpfad wird ausgeführt.'}
+ {fallbackStrategy === 'uncertainty_path' && 'Bei Unklarheit: Expliziter Klärungspfad.'}
+ {fallbackStrategy === 'document_only' && 'Unklarheit dokumentieren, aber kein Routing.'}
+
+
+ {(fallbackStrategy === 'default_path' || fallbackStrategy === 'uncertainty_path') && (
+ <>
+
Ziel-Edge
+
+ -- Kante wählen --
+ {outgoingEdges.map(e => (
+
+ {e.data?.label || `Edge → ${e.target}`}
+
+ ))}
+
+
+ {outgoingEdges.length === 0 && (
+
+ ⚠️ Keine ausgehenden Kanten gefunden. Bitte verbinden Sie diesen Node zuerst.
+
+ )}
+ >
+ )}
+
+ )
+}
diff --git a/frontend/src/components/workflow/panels/JoinConfig.jsx b/frontend/src/components/workflow/panels/JoinConfig.jsx
new file mode 100644
index 0000000..c954e43
--- /dev/null
+++ b/frontend/src/components/workflow/panels/JoinConfig.jsx
@@ -0,0 +1,67 @@
+/**
+ * JoinConfig - Konfiguration für Join Nodes
+ *
+ * Props:
+ * - node: React Flow Node object (type='join')
+ * - onChange: (nodeId, updates) => void
+ */
+export function JoinConfig({ node, onChange }) {
+ const joinStrategy = node.data.join_strategy || 'wait_all'
+ const skipHandling = node.data.skip_handling || 'ignore_skipped'
+ const minPaths = node.data.min_paths || 2
+
+ const handleStrategyChange = (e) => {
+ onChange(node.id, { join_strategy: e.target.value })
+ }
+
+ const handleSkipChange = (e) => {
+ onChange(node.id, { skip_handling: e.target.value })
+ }
+
+ const handleMinPathsChange = (e) => {
+ const value = parseInt(e.target.value) || 2
+ onChange(node.id, { min_paths: value })
+ }
+
+ return (
+
+
Join-Konfiguration
+
+
Join-Strategie
+
+ Alle Pfade warten (wait_all)
+ Mindestens ein Pfad (wait_any)
+ Verfügbare nutzen (best_effort)
+
+
+
+ {joinStrategy === 'wait_all' && 'Wartet auf alle eingehenden Pfade. Fehler wenn einer fehlt.'}
+ {joinStrategy === 'wait_any' && 'Wartet auf mindestens einen Pfad. Erste verfügbare Ausführung.'}
+ {joinStrategy === 'best_effort' && 'Fehlertoleranz: Nutzt verfügbare Pfade, auch wenn nicht alle da sind.'}
+
+
+
Skip-Handling
+
+ Übersprungene ignorieren
+ Platzhalter verwenden
+ Mindestanzahl erforderlich
+
+
+ {skipHandling === 'require_minimum' && (
+ <>
+
Mindestanzahl Pfade
+
+ >
+ )}
+
+
+ 💡 Phase 4: Path Consolidation
+
+
+ )
+}
diff --git a/frontend/src/components/workflow/panels/LogicExpressionEditor.jsx b/frontend/src/components/workflow/panels/LogicExpressionEditor.jsx
new file mode 100644
index 0000000..986b1e5
--- /dev/null
+++ b/frontend/src/components/workflow/panels/LogicExpressionEditor.jsx
@@ -0,0 +1,353 @@
+import { useState, useEffect, useMemo } from 'react'
+
+/**
+ * LogicExpressionEditor - Visueller Baukasten für Logik-Bedingungen
+ *
+ * KEIN JSON-Editor! Visuelles Drag & Drop Interface.
+ *
+ * Props:
+ * - node: React Flow Node object (type='logic')
+ * - nodes: Array of all nodes (for signal reference lookup)
+ * - edges: Array of all edges (for topological sort)
+ * - onChange: (nodeId, updates) => void
+ */
+export function LogicExpressionEditor({ node, nodes, edges, onChange }) {
+ const [expression, setExpression] = useState(node.data.condition || {
+ operator: 'and',
+ operands: []
+ })
+
+ // Verfügbare Signale aus vorangegangenen Nodes
+ const availableSignals = useMemo(() => {
+ return getAvailableSignals(node.id, nodes, edges)
+ }, [node.id, nodes, edges])
+
+ // Sync to parent when expression changes
+ useEffect(() => {
+ onChange(node.id, { condition: expression })
+ }, [expression])
+
+ const handleOperatorChange = (e) => {
+ setExpression({ ...expression, operator: e.target.value })
+ }
+
+ const handleAddCondition = () => {
+ setExpression({
+ ...expression,
+ operands: [...(expression.operands || []), {
+ ref: '',
+ operator: 'eq',
+ value: ''
+ }]
+ })
+ }
+
+ const handleAddGroup = () => {
+ setExpression({
+ ...expression,
+ operands: [...(expression.operands || []), {
+ operator: 'and',
+ operands: []
+ }]
+ })
+ }
+
+ const handleOperandChange = (index, updates) => {
+ const updated = [...(expression.operands || [])]
+ updated[index] = { ...updated[index], ...updates }
+ setExpression({ ...expression, operands: updated })
+ }
+
+ const handleRemoveOperand = (index) => {
+ setExpression({
+ ...expression,
+ operands: (expression.operands || []).filter((_, i) => i !== index)
+ })
+ }
+
+ return (
+
+
Logik-Bedingung
+
+ {availableSignals.length === 0 && (
+
+ ⚠️ Keine Signale verfügbar. Fügen Sie zuerst einen Analysis-Node VOR diesem Node hinzu.
+
+ )}
+
+
+ Verknüpfung (Root-Operator)
+
+ UND (alle müssen zutreffen)
+ ODER (mind. eine muss zutreffen)
+ NICHT (umkehren)
+
+
+
+
+ {(expression.operands || []).map((operand, idx) => (
+
handleOperandChange(idx, updates)}
+ onRemove={() => handleRemoveOperand(idx)}
+ />
+ ))}
+
+ {(!expression.operands || expression.operands.length === 0) && (
+
+ Keine Bedingungen definiert. Fügen Sie Bedingungen oder Gruppen hinzu.
+
+ )}
+
+
+
+
+ + Bedingung
+
+
+ + Gruppe (AND/OR)
+
+
+
+
+ 💡 Signale: {availableSignals.length} verfügbar
+
+
+ )
+}
+
+/**
+ * ConditionBlock - Einzelne Bedingung oder Gruppe (rekursiv)
+ */
+function ConditionBlock({ operand, availableSignals, onChange, onRemove }) {
+ // Verschachtelte Gruppe? (AND/OR/NOT)
+ const isGroup = operand.operator === 'and' || operand.operator === 'or' || operand.operator === 'not'
+
+ if (isGroup) {
+ return (
+
+
+ onChange({ operator: e.target.value })}
+ style={{ flex: 1 }}
+ >
+ UND
+ ODER
+ NICHT
+
+ 🗑️
+
+
+ {/* Rekursiv: Nested operands */}
+
+ {(operand.operands || []).map((subOp, idx) => (
+ {
+ const updated = [...(operand.operands || [])]
+ updated[idx] = { ...updated[idx], ...updates }
+ onChange({ operands: updated })
+ }}
+ onRemove={() => {
+ onChange({ operands: (operand.operands || []).filter((_, i) => i !== idx) })
+ }}
+ />
+ ))}
+
+ {
+ onChange({
+ operands: [...(operand.operands || []), { ref: '', operator: 'eq', value: '' }]
+ })
+ }}
+ style={{ marginTop: '8px', fontSize: '12px' }}
+ >
+ + Bedingung
+
+
+
+ )
+ }
+
+ // Einfache Bedingung
+ const selectedSignal = availableSignals.find(s => s.ref === operand.ref)
+
+ return (
+
+ {/* Signal-Referenz (Dropdown) */}
+ onChange({ ref: e.target.value })}
+ className="signal-select"
+ style={{ flex: 2 }}
+ >
+ -- Signal wählen --
+ {availableSignals.map(sig => (
+
+ {sig.label}
+
+ ))}
+
+
+ {/* Operator */}
+ onChange({ operator: e.target.value })}
+ className="operator-select"
+ style={{ flex: 1 }}
+ >
+ ==
+ !=
+ IN
+ NOT IN
+ >
+ <
+ >=
+ <=
+ CONTAINS
+
+
+ {/* Wert (abhängig von Operator) */}
+ {(operand.operator === 'in' || operand.operator === 'not_in') ? (
+ onChange({ value: val })}
+ placeholder="Werte wählen"
+ />
+ ) : (
+ onChange({ value: e.target.value })}
+ placeholder="Wert"
+ className="value-input"
+ style={{ flex: 1 }}
+ />
+ )}
+
+ 🗑️
+
+ )
+}
+
+/**
+ * MultiSelect - Multi-Auswahl für IN/NOT_IN Operatoren
+ */
+function MultiSelect({ options, value, onChange, placeholder }) {
+ const [isOpen, setIsOpen] = useState(false)
+ const selected = Array.isArray(value) ? value : []
+
+ const handleToggle = (option) => {
+ if (selected.includes(option)) {
+ onChange(selected.filter(v => v !== option))
+ } else {
+ onChange([...selected, option])
+ }
+ }
+
+ return (
+
+
setIsOpen(!isOpen)}
+ style={{
+ padding: '6px',
+ border: '1px solid var(--border)',
+ borderRadius: '4px',
+ cursor: 'pointer',
+ fontSize: '12px',
+ background: 'var(--bg)'
+ }}
+ >
+ {selected.length > 0 ? selected.join(', ') : placeholder}
+
+
+ {isOpen && (
+
+ {options.map((option, i) => (
+
handleToggle(option)}
+ style={{
+ padding: '8px',
+ cursor: 'pointer',
+ fontSize: '12px',
+ background: selected.includes(option) ? 'var(--accent)' : 'transparent',
+ color: selected.includes(option) ? 'white' : 'var(--text1)'
+ }}
+ >
+ {selected.includes(option) && '✓ '}
+ {option}
+
+ ))}
+
+ {options.length === 0 && (
+
+ Keine Optionen verfügbar
+
+ )}
+
+ )}
+
+ )
+}
+
+/**
+ * Helper: Verfügbare Signale aus vorangegangenen Nodes extrahieren
+ */
+function getAvailableSignals(nodeId, nodes, edges) {
+ // Topologische Sortierung: Alle Nodes VOR diesem Node
+ const predecessors = new Set()
+
+ function collectPredecessors(currentId) {
+ const incoming = edges.filter(e => e.target === currentId)
+ for (const edge of incoming) {
+ if (!predecessors.has(edge.source)) {
+ predecessors.add(edge.source)
+ collectPredecessors(edge.source)
+ }
+ }
+ }
+
+ collectPredecessors(nodeId)
+
+ // Signale extrahieren (nur von ANALYSIS Nodes)
+ const signals = []
+ for (const predId of predecessors) {
+ const predNode = nodes.find(n => n.id === predId)
+ if (predNode && predNode.type === 'analysis') {
+ const questions = predNode.data.questions || []
+ for (const q of questions) {
+ signals.push({
+ ref: `${predId}.${q.id}`,
+ label: `${predNode.data.label} → ${q.question || q.id}`,
+ spectrum: q.answer_spectrum || []
+ })
+ }
+ }
+ }
+
+ return signals
+}
diff --git a/frontend/src/components/workflow/panels/QuestionAugmentationPanel.jsx b/frontend/src/components/workflow/panels/QuestionAugmentationPanel.jsx
new file mode 100644
index 0000000..2def832
--- /dev/null
+++ b/frontend/src/components/workflow/panels/QuestionAugmentationPanel.jsx
@@ -0,0 +1,218 @@
+import { useState, useEffect } from 'react'
+
+/**
+ * QuestionAugmentationPanel - Fragenergänzungs-Konfiguration
+ *
+ * Props:
+ * - node: React Flow Node object (type='analysis')
+ * - onChange: (nodeId, updates) => void
+ */
+export function QuestionAugmentationPanel({ node, onChange }) {
+ const [questions, setQuestions] = useState(node.data.questions || [])
+
+ // Sync to parent when questions change
+ useEffect(() => {
+ onChange(node.id, { questions })
+ }, [questions])
+
+ const handleAddQuestion = () => {
+ const newQuestion = {
+ id: `q${Date.now()}`,
+ type: 'relevanz',
+ question: '',
+ answer_spectrum: []
+ }
+ setQuestions([...questions, newQuestion])
+ }
+
+ const handleRemoveQuestion = (index) => {
+ setQuestions(questions.filter((_, i) => i !== index))
+ }
+
+ const handleQuestionChange = (index, field, value) => {
+ const updated = [...questions]
+ updated[index] = { ...updated[index], [field]: value }
+ setQuestions(updated)
+ }
+
+ return (
+
+
Fragenergänzung
+
+ {questions.length === 0 && (
+
+ Keine Fragen definiert. Fügen Sie Fragen hinzu, um Signale für Logik-Knoten zu erzeugen.
+
+ )}
+
+ {questions.map((q, idx) => (
+
handleQuestionChange(idx, field, value)}
+ onRemove={() => handleRemoveQuestion(idx)}
+ />
+ ))}
+
+
+ + Frage hinzufügen
+
+
+ )
+}
+
+/**
+ * QuestionEditor - Einzelne Frage editieren
+ */
+function QuestionEditor({ question, index, onChange, onRemove }) {
+ const [spectrumInput, setSpectrumInput] = useState('')
+
+ const handleAddAnswer = () => {
+ if (!spectrumInput.trim()) return
+
+ const newSpectrum = [...(question.answer_spectrum || []), spectrumInput.trim()]
+ onChange('answer_spectrum', newSpectrum)
+ setSpectrumInput('')
+ }
+
+ const handleRemoveAnswer = (answer) => {
+ const newSpectrum = question.answer_spectrum.filter(a => a !== answer)
+ onChange('answer_spectrum', newSpectrum)
+ }
+
+ const handleKeyPress = (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ handleAddAnswer()
+ }
+ }
+
+ return (
+
+
+ Frage {index + 1}
+
+ 🗑️
+
+
+
+
Frage-ID
+
onChange('id', e.target.value)}
+ placeholder="z.B. relevanz"
+ />
+
+
Fragetyp
+
onChange('type', e.target.value)}
+ >
+ Relevanz
+ Priorität
+ Selektion
+ Ausschluss
+ Eskalation
+ Unsicherheit
+
+
+
Fragetext
+
+ )
+}
+
+// Zusätzliches CSS für Tags (inline styles)
+const tagStyles = `
+.tag-input-container {
+ margin-bottom: 8px;
+}
+
+.tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-bottom: 8px;
+}
+
+.tag {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 8px;
+ background: var(--accent);
+ color: white;
+ border-radius: 4px;
+ font-size: 12px;
+ font-weight: 500;
+}
+
+.tag-remove {
+ background: none;
+ border: none;
+ color: white;
+ cursor: pointer;
+ font-size: 16px;
+ line-height: 1;
+ padding: 0;
+ margin-left: 2px;
+ opacity: 0.8;
+}
+
+.tag-remove:hover {
+ opacity: 1;
+}
+`
+
+// Inject styles (nur einmal)
+if (typeof document !== 'undefined' && !document.getElementById('question-panel-styles')) {
+ const styleEl = document.createElement('style')
+ styleEl.id = 'question-panel-styles'
+ styleEl.textContent = tagStyles
+ document.head.appendChild(styleEl)
+}
diff --git a/frontend/src/pages/WorkflowEditorPage.jsx b/frontend/src/pages/WorkflowEditorPage.jsx
new file mode 100644
index 0000000..3ccb279
--- /dev/null
+++ b/frontend/src/pages/WorkflowEditorPage.jsx
@@ -0,0 +1,392 @@
+import { useState, useCallback, useEffect } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import { useNodesState, useEdgesState, addEdge } from 'reactflow'
+import { api } from '../utils/api'
+import { validateWorkflowGraph } from '../utils/workflowValidation'
+import { serializeToWorkflowGraph, deserializeFromWorkflowGraph } from '../utils/workflowSerializer'
+import { WorkflowCanvas } from '../components/workflow/WorkflowCanvas'
+import { StartNode } from '../components/workflow/nodes/StartNode'
+import { EndNode } from '../components/workflow/nodes/EndNode'
+import { AnalysisNode } from '../components/workflow/nodes/AnalysisNode'
+import { LogicNode } from '../components/workflow/nodes/LogicNode'
+import { JoinNode } from '../components/workflow/nodes/JoinNode'
+import { QuestionAugmentationPanel } from '../components/workflow/panels/QuestionAugmentationPanel'
+import { LogicExpressionEditor } from '../components/workflow/panels/LogicExpressionEditor'
+import { FallbackConfig } from '../components/workflow/panels/FallbackConfig'
+import { JoinConfig } from '../components/workflow/panels/JoinConfig'
+import '../styles/workflowEditor.css'
+
+// Node-Type Mapping
+const nodeTypes = {
+ start: StartNode,
+ end: EndNode,
+ analysis: AnalysisNode,
+ logic: LogicNode,
+ join: JoinNode
+}
+
+let nodeIdCounter = 1
+
+export default function WorkflowEditorPage() {
+ const navigate = useNavigate()
+ const { id } = useParams() // prompt_id wenn vorhanden
+
+ // State
+ const [nodes, setNodes, onNodesChange] = useNodesState([])
+ const [edges, setEdges, onEdgesChange] = useEdgesState([])
+ const [selectedNode, setSelectedNode] = useState(null)
+ const [currentPrompt, setCurrentPrompt] = useState(null)
+ const [workflowName, setWorkflowName] = useState('Neuer Workflow')
+ const [workflowDescription, setWorkflowDescription] = useState('')
+ const [validationErrors, setValidationErrors] = useState([])
+ const [validationWarnings, setValidationWarnings] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ // Load workflow wenn ID vorhanden
+ useEffect(() => {
+ if (id && id !== 'new') {
+ loadWorkflow(parseInt(id))
+ }
+ }, [id])
+
+ // Auto-Validation
+ useEffect(() => {
+ const { errors, warnings } = validateWorkflowGraph(nodes, edges)
+ setValidationErrors(errors)
+ setValidationWarnings(warnings)
+ }, [nodes, edges])
+
+ // ── Handlers ──────────────────────────────────────────────────────────────
+
+ const onConnect = useCallback(
+ (params) => setEdges((eds) => addEdge(params, eds)),
+ [setEdges]
+ )
+
+ const onNodeClick = useCallback((event, node) => {
+ setSelectedNode(node)
+ }, [])
+
+ const handleAddNode = (nodeType) => {
+ const newNode = {
+ id: `node_${nodeIdCounter++}`,
+ type: nodeType,
+ position: { x: 250, y: 100 + nodes.length * 100 },
+ data: {
+ label: `${nodeType.charAt(0).toUpperCase() + nodeType.slice(1)} ${nodeIdCounter - 1}`
+ }
+ }
+
+ setNodes((nds) => [...nds, newNode])
+ }
+
+ const handleNodeUpdate = (nodeId, updates) => {
+ setNodes((nds) =>
+ nds.map((n) => (n.id === nodeId ? { ...n, data: { ...n.data, ...updates } } : n))
+ )
+ }
+
+ const handleDeleteNode = () => {
+ if (!selectedNode) return
+
+ setNodes((nds) => nds.filter((n) => n.id !== selectedNode.id))
+ setEdges((eds) => eds.filter((e) => e.source !== selectedNode.id && e.target !== selectedNode.id))
+ setSelectedNode(null)
+ }
+
+ const handleSave = async () => {
+ try {
+ setLoading(true)
+ setError(null)
+
+ // Validierung
+ const { errors, isValid } = validateWorkflowGraph(nodes, edges)
+ if (!isValid) {
+ setError(`Validierung fehlgeschlagen: ${errors.length} Fehler gefunden`)
+ return
+ }
+
+ // Serialisieren
+ const graph_data = serializeToWorkflowGraph(nodes, edges, {
+ created_at: currentPrompt?.created_at,
+ version: '1.0'
+ })
+
+ if (currentPrompt) {
+ // Update existing
+ await api.updateUnifiedPrompt(currentPrompt.id, {
+ type: 'workflow',
+ name: workflowName,
+ description: workflowDescription,
+ graph_data
+ })
+ alert('Workflow gespeichert!')
+ } else {
+ // Create new
+ const result = await api.createUnifiedPrompt({
+ type: 'workflow',
+ name: workflowName,
+ description: workflowDescription,
+ graph_data
+ })
+ setCurrentPrompt({ id: result.id, name: workflowName })
+ navigate(`/workflow-editor/${result.id}`)
+ alert('Workflow erstellt!')
+ }
+ } catch (e) {
+ setError(e.message)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const loadWorkflow = async (promptId) => {
+ try {
+ setLoading(true)
+ setError(null)
+
+ const prompt = await api.getPrompt(promptId)
+
+ if (prompt.type !== 'workflow') {
+ throw new Error('Nicht ein Workflow')
+ }
+
+ // Deserialisieren
+ const { nodes: loadedNodes, edges: loadedEdges } = deserializeFromWorkflowGraph(prompt.graph_data)
+
+ setNodes(loadedNodes)
+ setEdges(loadedEdges)
+ setCurrentPrompt(prompt)
+ setWorkflowName(prompt.name)
+ setWorkflowDescription(prompt.description || '')
+
+ // nodeIdCounter aktualisieren
+ const maxId = Math.max(
+ ...loadedNodes.map((n) => parseInt(n.id.replace('node_', '')) || 0),
+ 0
+ )
+ nodeIdCounter = maxId + 1
+ } catch (e) {
+ setError(e.message)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleValidate = () => {
+ const { errors, warnings } = validateWorkflowGraph(nodes, edges)
+ setValidationErrors(errors)
+ setValidationWarnings(warnings)
+
+ if (errors.length === 0) {
+ alert(`✅ Workflow ist valide!\n\n${warnings.length} Warnungen`)
+ } else {
+ alert(`❌ Validierung fehlgeschlagen!\n\n${errors.length} Fehler, ${warnings.length} Warnungen`)
+ }
+ }
+
+ const handleNew = () => {
+ if (confirm('Neuen Workflow erstellen? Ungespeicherte Änderungen gehen verloren.')) {
+ setNodes([])
+ setEdges([])
+ setCurrentPrompt(null)
+ setWorkflowName('Neuer Workflow')
+ setWorkflowDescription('')
+ setSelectedNode(null)
+ navigate('/workflow-editor/new')
+ }
+ }
+
+ const handleDelete = async () => {
+ if (!currentPrompt) return
+
+ if (!confirm(`Workflow "${workflowName}" wirklich löschen?`)) return
+
+ try {
+ setLoading(true)
+ await api.deletePrompt(currentPrompt.id)
+ alert('Workflow gelöscht')
+ navigate('/admin/prompts')
+ } catch (e) {
+ setError(e.message)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ // ── Render ────────────────────────────────────────────────────────────────
+
+ return (
+
+ {/* Toolbar */}
+
+ navigate('/admin/prompts')}>
+ ← Zurück
+
+
+ setWorkflowName(e.target.value)}
+ placeholder="Workflow-Name"
+ style={{ flex: 1, padding: '8px', borderRadius: '4px', border: '1px solid var(--border)' }}
+ />
+
+
+ Neu
+
+
+ Validieren
+
+
+ {loading ? 'Speichern...' : 'Speichern'}
+
+ {currentPrompt && (
+
+ Löschen
+
+ )}
+
+
+ {error && (
+
+ ❌ {error}
+
+ )}
+
+ {/* Main Content */}
+
+ {/* Sidebar */}
+
+
+
Workflow-Knoten
+
+ handleAddNode('start')}>
+ 🚀 Start
+
+ handleAddNode('analysis')}>
+ 🤖 Analyse
+
+ handleAddNode('logic')}>
+ ⚡ Logik
+
+ handleAddNode('join')}>
+ 🔀 Join
+
+ handleAddNode('end')}>
+ 🏁 Ende
+
+
+
+
+ {selectedNode && (
+
+
Aktionen
+
+ 🗑️ Node löschen
+
+
+ )}
+
+
+
Info
+
+
Nodes: {nodes.length}
+
Edges: {edges.length}
+
Errors: {validationErrors.length}
+
Warnings: {validationWarnings.length}
+
+
+
+
+ {/* Canvas */}
+
+
+
+
+ {/* Config Panel */}
+ {selectedNode && (
+
+
{selectedNode.data.label}
+
+ {/* Basis-Konfiguration */}
+
+ Label
+ handleNodeUpdate(selectedNode.id, { label: e.target.value })}
+ />
+
+
+ {/* Type-spezifische Konfiguration */}
+ {selectedNode.type === 'analysis' && (
+ <>
+
+
+ >
+ )}
+
+ {selectedNode.type === 'logic' && (
+ <>
+
+
+ >
+ )}
+
+ {selectedNode.type === 'join' && (
+
+ )}
+
+ )}
+
+
+ {/* Validation Panel */}
+ {(validationErrors.length > 0 || validationWarnings.length > 0) && (
+
+ {validationErrors.map((err, i) => (
+
{
+ if (err.nodeId) {
+ const node = nodes.find(n => n.id === err.nodeId)
+ if (node) setSelectedNode(node)
+ }
+ }}>
+ ❌ {err.message}
+
+ ))}
+
+ {validationWarnings.map((warn, i) => (
+
{
+ if (warn.nodeId) {
+ const node = nodes.find(n => n.id === warn.nodeId)
+ if (node) setSelectedNode(node)
+ }
+ }}>
+ ⚠️ {warn.message}
+
+ ))}
+
+ {validationErrors.length === 0 && validationWarnings.length > 0 && (
+
+ ✅ Workflow ist valide ({validationWarnings.length} Warnungen)
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/styles/workflowEditor.css b/frontend/src/styles/workflowEditor.css
new file mode 100644
index 0000000..1ff2c0b
--- /dev/null
+++ b/frontend/src/styles/workflowEditor.css
@@ -0,0 +1,471 @@
+/* Workflow Editor Styles (Phase 5) */
+
+/* ── Editor Layout ────────────────────────────────────────────────────────── */
+
+.workflow-editor {
+ display: flex;
+ flex-direction: column;
+ height: calc(100vh - 60px);
+ background: var(--bg);
+}
+
+.workflow-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 16px;
+ background: var(--surface);
+ border-bottom: 1px solid var(--border);
+}
+
+.workflow-content {
+ display: flex;
+ flex: 1;
+ overflow: hidden;
+}
+
+/* ── Sidebar (Node Palette) ─────────────────────────────────────────────── */
+
+.workflow-sidebar {
+ width: 250px;
+ background: var(--surface);
+ border-right: 1px solid var(--border);
+ padding: 16px;
+ overflow-y: auto;
+}
+
+.sidebar-section {
+ margin-bottom: 24px;
+}
+
+.sidebar-section h3 {
+ margin: 0 0 12px 0;
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text2);
+ text-transform: uppercase;
+}
+
+.node-palette {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.node-palette-button {
+ padding: 12px;
+ background: var(--surface2);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 14px;
+ color: var(--text1);
+ transition: all 0.2s;
+}
+
+.node-palette-button:hover {
+ background: var(--accent);
+ color: white;
+ border-color: var(--accent);
+ transform: translateY(-1px);
+}
+
+.node-palette-button .icon {
+ font-size: 20px;
+}
+
+/* ── Canvas ──────────────────────────────────────────────────────────────── */
+
+.workflow-canvas-container {
+ flex: 1;
+ position: relative;
+ background: var(--bg);
+}
+
+/* React Flow Overrides */
+.react-flow {
+ background: var(--bg);
+}
+
+.react-flow__node {
+ border-radius: 8px;
+}
+
+.react-flow__node.selected {
+ box-shadow: 0 0 0 3px rgba(29, 158, 117, 0.3);
+}
+
+.react-flow__edge-path {
+ stroke: var(--text3);
+ stroke-width: 2;
+}
+
+.react-flow__edge.selected .react-flow__edge-path {
+ stroke: var(--accent);
+ stroke-width: 3;
+}
+
+.react-flow__handle {
+ width: 10px;
+ height: 10px;
+ border: 2px solid white;
+}
+
+.react-flow__handle-connecting {
+ background: var(--accent) !important;
+}
+
+.react-flow__handle-valid {
+ background: #4CAF50 !important;
+}
+
+/* ── Custom Nodes ────────────────────────────────────────────────────────── */
+
+.workflow-node {
+ min-width: 180px;
+ background: var(--surface);
+ border: 2px solid var(--border);
+ border-radius: 8px;
+ padding: 12px;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+ font-family: inherit;
+ transition: all 0.2s;
+}
+
+.workflow-node:hover {
+ box-shadow: 0 4px 8px rgba(0,0,0,0.15);
+ transform: translateY(-1px);
+}
+
+.workflow-node.selected {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px rgba(29, 158, 117, 0.2);
+}
+
+.node-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+
+.node-icon {
+ font-size: 20px;
+ line-height: 1;
+}
+
+.node-label {
+ font-weight: 600;
+ font-size: 14px;
+ color: var(--text1);
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.node-body {
+ font-size: 12px;
+ color: var(--text2);
+}
+
+/* Start Node */
+.workflow-node.start-node {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ border-color: #667eea;
+ text-align: center;
+}
+
+.workflow-node.start-node .node-label,
+.workflow-node.start-node .node-body {
+ color: white;
+}
+
+/* End Node */
+.workflow-node.end-node {
+ background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
+ color: white;
+ border-color: #f093fb;
+ text-align: center;
+}
+
+.workflow-node.end-node .node-label,
+.workflow-node.end-node .node-body {
+ color: white;
+}
+
+/* Analysis Node */
+.workflow-node.analysis-node {
+ background: var(--surface2);
+ border-color: var(--accent);
+}
+
+.workflow-node.analysis-node .prompt-name {
+ font-weight: 500;
+ color: var(--text1);
+ margin-bottom: 4px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.workflow-node.analysis-node .questions-indicator {
+ font-size: 11px;
+ color: var(--accent);
+ padding: 4px 8px;
+ background: rgba(29, 158, 117, 0.1);
+ border-radius: 4px;
+ display: inline-block;
+ margin-top: 4px;
+}
+
+/* Logic Node */
+.workflow-node.logic-node {
+ background: #FFF3CD;
+ border-color: #FFC107;
+}
+
+.workflow-node.logic-node .condition-summary {
+ padding: 6px 8px;
+ background: rgba(255, 193, 7, 0.2);
+ border-radius: 4px;
+ font-size: 11px;
+ font-weight: 500;
+}
+
+.workflow-node.logic-node .condition-summary.has-condition {
+ color: #856404;
+}
+
+.workflow-node.logic-node .condition-summary.no-condition {
+ color: #6c757d;
+ font-style: italic;
+}
+
+/* Join Node */
+.workflow-node.join-node {
+ background: #D1ECF1;
+ border-color: #17A2B8;
+}
+
+.workflow-node.join-node .node-body {
+ font-size: 11px;
+}
+
+.workflow-node.join-node .strategy,
+.workflow-node.join-node .skip-handling {
+ margin-bottom: 4px;
+}
+
+.workflow-node.join-node strong {
+ color: #0c5460;
+}
+
+/* ── Config Panel ────────────────────────────────────────────────────────── */
+
+.workflow-config-panel {
+ width: 400px;
+ background: var(--surface);
+ border-left: 1px solid var(--border);
+ padding: 16px;
+ overflow-y: auto;
+}
+
+.config-section {
+ margin-bottom: 24px;
+}
+
+.config-section h3 {
+ margin: 0 0 12px 0;
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--text1);
+}
+
+.config-section label {
+ display: block;
+ margin-bottom: 4px;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text2);
+}
+
+.config-section input,
+.config-section select,
+.config-section textarea {
+ width: 100%;
+ padding: 8px;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ margin-bottom: 8px;
+ font-family: inherit;
+ font-size: 14px;
+ background: var(--bg);
+ color: var(--text1);
+}
+
+.config-section textarea {
+ resize: vertical;
+ min-height: 60px;
+}
+
+/* ── Question Editor ─────────────────────────────────────────────────────── */
+
+.question-editor {
+ background: var(--surface2);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 12px;
+ margin-bottom: 12px;
+}
+
+.question-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 8px;
+}
+
+.question-header span {
+ font-weight: 600;
+ font-size: 14px;
+ color: var(--text1);
+}
+
+.btn-icon {
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-size: 18px;
+ padding: 4px;
+ opacity: 0.7;
+ transition: opacity 0.2s;
+}
+
+.btn-icon:hover {
+ opacity: 1;
+}
+
+/* ── Logic Expression Editor ─────────────────────────────────────────────── */
+
+.logic-root {
+ margin-bottom: 16px;
+}
+
+.logic-operands {
+ margin-top: 12px;
+}
+
+.condition-block {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ padding: 8px;
+ margin-bottom: 8px;
+}
+
+.condition-simple {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+
+.condition-simple select,
+.condition-simple input {
+ flex: 1;
+ padding: 6px;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ font-size: 12px;
+}
+
+.condition-group {
+ border-left: 3px solid var(--accent);
+ padding-left: 12px;
+ margin-bottom: 8px;
+}
+
+.group-header {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ margin-bottom: 8px;
+}
+
+.logic-actions {
+ display: flex;
+ gap: 8px;
+ margin-top: 12px;
+}
+
+/* ── Validation Panel ────────────────────────────────────────────────────── */
+
+.validation-panel {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: var(--surface);
+ border-top: 1px solid var(--border);
+ padding: 12px 16px;
+ max-height: 200px;
+ overflow-y: auto;
+ z-index: 1000;
+}
+
+.validation-error {
+ color: var(--danger);
+ margin-bottom: 4px;
+ font-size: 14px;
+ cursor: pointer;
+}
+
+.validation-error:hover {
+ text-decoration: underline;
+}
+
+.validation-warning {
+ color: #FFC107;
+ margin-bottom: 4px;
+ font-size: 14px;
+ cursor: pointer;
+}
+
+.validation-warning:hover {
+ text-decoration: underline;
+}
+
+.validation-success {
+ color: #4CAF50;
+ font-size: 14px;
+ font-weight: 600;
+}
+
+/* ── Responsive ──────────────────────────────────────────────────────────── */
+
+@media (max-width: 1200px) {
+ .workflow-sidebar {
+ width: 200px;
+ }
+
+ .workflow-config-panel {
+ width: 350px;
+ }
+}
+
+@media (max-width: 900px) {
+ .workflow-content {
+ flex-direction: column;
+ }
+
+ .workflow-sidebar,
+ .workflow-config-panel {
+ width: 100%;
+ border: none;
+ border-bottom: 1px solid var(--border);
+ }
+}
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index aa59ffc..73dee1e 100644
--- a/frontend/src/utils/api.js
+++ b/frontend/src/utils/api.js
@@ -286,6 +286,7 @@ export const api = {
// AI Prompts Management (Issue #28)
listAdminPrompts: () => req('/prompts'),
+ getPrompt: (id) => req(`/prompts/${id}`),
createPrompt: (d) => req('/prompts', json(d)),
updatePrompt: (id,d) => req(`/prompts/${id}`, jput(d)),
deletePrompt: (id) => req(`/prompts/${id}`, {method:'DELETE'}),
diff --git a/frontend/src/utils/workflowSerializer.js b/frontend/src/utils/workflowSerializer.js
new file mode 100644
index 0000000..2b1ebce
--- /dev/null
+++ b/frontend/src/utils/workflowSerializer.js
@@ -0,0 +1,117 @@
+/**
+ * Workflow Serialization Utilities
+ *
+ * Konvertiert zwischen React Flow (Canvas) und Backend-Format (JSONB).
+ */
+
+/**
+ * Serialisiert React Flow Graph zu Backend-kompatiblem Format
+ *
+ * @param {Array} nodes - React Flow nodes
+ * @param {Array} edges - React Flow edges
+ * @param {Object} metadata - Zusätzliche Metadaten
+ * @returns {Object} JSONB-kompatibles Objekt für ai_prompts.graph_data
+ */
+export function serializeToWorkflowGraph(nodes, edges, metadata = {}) {
+ const workflowNodes = nodes.map(node => ({
+ id: node.id,
+ type: node.type,
+ label: node.data.label || node.type,
+ position: { x: node.position.x, y: node.position.y },
+
+ // Type-spezifische Felder
+ ...(node.type === 'analysis' && {
+ prompt_id: node.data.prompt_id || null,
+ questions: node.data.questions || [],
+ fallback_strategy: node.data.fallback_strategy || 'conservative_skip'
+ }),
+
+ ...(node.type === 'logic' && {
+ condition: node.data.condition || null,
+ fallback_strategy: node.data.fallback_strategy || 'conservative_skip'
+ }),
+
+ ...(node.type === 'join' && {
+ join_strategy: node.data.join_strategy || 'wait_all',
+ skip_handling: node.data.skip_handling || 'ignore_skipped',
+ min_paths: node.data.min_paths || 2
+ })
+ }))
+
+ const workflowEdges = edges.map(edge => ({
+ id: edge.id,
+ source: edge.source,
+ target: edge.target,
+ label: edge.data?.label || null,
+ sourceHandle: edge.sourceHandle || null,
+ targetHandle: edge.targetHandle || null
+ }))
+
+ return {
+ nodes: workflowNodes,
+ edges: workflowEdges,
+ metadata: {
+ created_at: metadata.created_at || new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ version: metadata.version || '1.0'
+ }
+ }
+}
+
+/**
+ * Deserialisiert Backend-Format zu React Flow Graph
+ *
+ * @param {Object} jsonbData - ai_prompts.graph_data (JSONB)
+ * @returns {Object} { nodes, edges, metadata }
+ */
+export function deserializeFromWorkflowGraph(jsonbData) {
+ if (!jsonbData || !jsonbData.nodes || !jsonbData.edges) {
+ throw new Error('Invalid workflow graph data')
+ }
+
+ const reactFlowNodes = jsonbData.nodes.map(node => ({
+ id: node.id,
+ type: node.type,
+ position: { x: node.position.x, y: node.position.y },
+ data: {
+ label: node.label,
+
+ ...(node.type === 'analysis' && {
+ prompt_id: node.prompt_id,
+ prompt_name: node.prompt_name || null, // Falls vom Backend mitgeliefert
+ questions: node.questions || [],
+ fallback_strategy: node.fallback_strategy || 'conservative_skip'
+ }),
+
+ ...(node.type === 'logic' && {
+ condition: node.condition || null,
+ fallback_strategy: node.fallback_strategy || 'conservative_skip'
+ }),
+
+ ...(node.type === 'join' && {
+ join_strategy: node.join_strategy || 'wait_all',
+ skip_handling: node.skip_handling || 'ignore_skipped',
+ min_paths: node.min_paths || 2
+ })
+ }
+ }))
+
+ const reactFlowEdges = jsonbData.edges.map(edge => ({
+ id: edge.id,
+ source: edge.source,
+ target: edge.target,
+ sourceHandle: edge.sourceHandle || null,
+ targetHandle: edge.targetHandle || null,
+ data: {
+ label: edge.label || null
+ },
+ type: 'default',
+ animated: false
+ }))
+
+ return {
+ nodes: reactFlowNodes,
+ edges: reactFlowEdges,
+ metadata: jsonbData.metadata || {}
+ }
+}
diff --git a/frontend/src/utils/workflowValidation.js b/frontend/src/utils/workflowValidation.js
new file mode 100644
index 0000000..18c2db2
--- /dev/null
+++ b/frontend/src/utils/workflowValidation.js
@@ -0,0 +1,226 @@
+/**
+ * Workflow Validation Utilities
+ *
+ * Validiert Workflow-Graphen (Struktur + Logik).
+ */
+
+export function validateWorkflowGraph(nodes, edges) {
+ const errors = []
+ const warnings = []
+
+ // 1. Strukturelle Validierung
+ validateStructure(nodes, edges, errors, warnings)
+
+ // 2. Logische Validierung
+ validateLogic(nodes, edges, errors, warnings)
+
+ return {
+ errors,
+ warnings,
+ isValid: errors.length === 0
+ }
+}
+
+/**
+ * Strukturelle Validierung (DAG, START/END, Zyklen)
+ */
+function validateStructure(nodes, edges, errors, warnings) {
+ // START Node
+ const startNodes = nodes.filter(n => n.type === 'start')
+ if (startNodes.length === 0) {
+ errors.push({
+ type: 'structure',
+ message: 'Kein START-Node vorhanden',
+ severity: 'error'
+ })
+ } else if (startNodes.length > 1) {
+ errors.push({
+ type: 'structure',
+ message: `${startNodes.length} START-Nodes gefunden (max. 1 erlaubt)`,
+ severity: 'error'
+ })
+ }
+
+ // END Node
+ const endNodes = nodes.filter(n => n.type === 'end')
+ if (endNodes.length === 0) {
+ errors.push({
+ type: 'structure',
+ message: 'Kein END-Node vorhanden',
+ severity: 'error'
+ })
+ }
+
+ // Zyklen-Erkennung
+ if (detectCycles(nodes, edges)) {
+ errors.push({
+ type: 'structure',
+ message: 'Workflow enthält Zyklen (nicht erlaubt)',
+ severity: 'error'
+ })
+ }
+
+ // Isolierte Nodes
+ nodes.forEach(node => {
+ if (node.type === 'start' || node.type === 'end') return
+
+ const hasIncoming = edges.some(e => e.target === node.id)
+ const hasOutgoing = edges.some(e => e.source === node.id)
+
+ if (!hasIncoming || !hasOutgoing) {
+ warnings.push({
+ type: 'isolation',
+ message: `Node "${node.data.label}" ist isoliert (keine/fehlende Verbindungen)`,
+ nodeId: node.id,
+ severity: 'warning'
+ })
+ }
+ })
+}
+
+/**
+ * Logische Validierung (Node-Konfiguration)
+ */
+function validateLogic(nodes, edges, errors, warnings) {
+ nodes.forEach(node => {
+ // Analysis Nodes
+ if (node.type === 'analysis') {
+ const questions = node.data.questions || []
+
+ // Prompt ausgewählt?
+ if (!node.data.prompt_id) {
+ errors.push({
+ type: 'config',
+ message: `Analysis-Node "${node.data.label}" hat keinen Prompt`,
+ nodeId: node.id,
+ severity: 'error'
+ })
+ }
+
+ // Fragen validieren
+ questions.forEach((q, idx) => {
+ if (!q.question?.trim()) {
+ errors.push({
+ type: 'config',
+ message: `Frage ${idx + 1} in "${node.data.label}" hat keinen Text`,
+ nodeId: node.id,
+ severity: 'error'
+ })
+ }
+
+ if (!q.answer_spectrum || q.answer_spectrum.length < 2) {
+ errors.push({
+ type: 'config',
+ message: `Frage ${idx + 1} in "${node.data.label}" braucht mind. 2 Antworten`,
+ nodeId: node.id,
+ severity: 'error'
+ })
+ }
+ })
+ }
+
+ // Logic Nodes
+ if (node.type === 'logic') {
+ const condition = node.data.condition
+
+ if (!condition || !condition.operator) {
+ errors.push({
+ type: 'config',
+ message: `Logic-Node "${node.data.label}" hat keine Bedingung`,
+ nodeId: node.id,
+ severity: 'error'
+ })
+ } else {
+ // Bedingung vollständig?
+ const incomplete = findIncompleteConditions(condition)
+ if (incomplete.length > 0) {
+ errors.push({
+ type: 'config',
+ message: `Logic-Node "${node.data.label}" hat ${incomplete.length} unvollständige Bedingung(en)`,
+ nodeId: node.id,
+ severity: 'error'
+ })
+ }
+ }
+
+ // Mind. 2 Outgoing Edges (true/false Pfade)
+ const outgoing = edges.filter(e => e.source === node.id)
+ if (outgoing.length < 2) {
+ warnings.push({
+ type: 'config',
+ message: `Logic-Node "${node.data.label}" hat nur ${outgoing.length} Ausgang (sollte mind. 2 haben)`,
+ nodeId: node.id,
+ severity: 'warning'
+ })
+ }
+ }
+
+ // Join Nodes
+ if (node.type === 'join') {
+ const incoming = edges.filter(e => e.target === node.id)
+
+ if (incoming.length < 2) {
+ warnings.push({
+ type: 'config',
+ message: `Join-Node "${node.data.label}" hat nur ${incoming.length} eingehende Kante (sollte mind. 2 haben)`,
+ nodeId: node.id,
+ severity: 'warning'
+ })
+ }
+ }
+ })
+}
+
+/**
+ * Zyklen-Erkennung (DFS-basiert)
+ */
+function detectCycles(nodes, edges) {
+ const visited = new Set()
+ const recStack = new Set()
+
+ function dfs(nodeId) {
+ visited.add(nodeId)
+ recStack.add(nodeId)
+
+ const outgoing = edges.filter(e => e.source === nodeId)
+ for (const edge of outgoing) {
+ if (!visited.has(edge.target)) {
+ if (dfs(edge.target)) return true
+ } else if (recStack.has(edge.target)) {
+ return true // Cycle detected
+ }
+ }
+
+ recStack.delete(nodeId)
+ return false
+ }
+
+ for (const node of nodes) {
+ if (!visited.has(node.id)) {
+ if (dfs(node.id)) return true
+ }
+ }
+
+ return false
+}
+
+/**
+ * Unvollständige Bedingungen finden (rekursiv)
+ */
+function findIncompleteConditions(condition) {
+ const incomplete = []
+
+ // Verschachtelte Gruppe?
+ if (condition.operands && Array.isArray(condition.operands)) {
+ for (const op of condition.operands) {
+ incomplete.push(...findIncompleteConditions(op))
+ }
+ } else {
+ // Einfache Bedingung: ref, operator, value müssen gesetzt sein
+ if (!condition.ref || !condition.operator || condition.value === undefined || condition.value === '') {
+ incomplete.push(condition)
+ }
+ }
+
+ return incomplete
+}