AP1.10b/10c: Entity Field System mit dynamischen Vorhaben-Zusatzfeldern.
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 1m54s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 19s
Test Suite / playwright-smoke (push) Successful in 15s
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 1m54s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 19s
Test Suite / playwright-smoke (push) Successful in 15s
EFS-Tabellen, Registry-Sync und Fields-API ermöglichen archetyp-spezifische Felder; Frontend rendert sie im Profil-Modal per FieldRenderer. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
408050bcb8
commit
a6fc86025c
|
|
@ -1,9 +1,13 @@
|
|||
"""System-Archetypen (AP1.10a) — Seed-Daten bis EFS-Tabellen (10b)."""
|
||||
"""System-Archetypen (AP1.10a/10b) — In-Memory-Seed + DB-Registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
from db import get_connection
|
||||
|
||||
INITIATIVE_ARCHETYPES: tuple[dict[str, Any], ...] = (
|
||||
{
|
||||
"key": "initiative.generic",
|
||||
|
|
@ -33,12 +37,44 @@ _ARCHETYPE_INDEX = {
|
|||
}
|
||||
|
||||
|
||||
def list_archetypes(*, entity_type: Optional[str] = None) -> list[dict[str, Any]]:
|
||||
def _list_archetypes_from_memory(*, entity_type: Optional[str] = None) -> list[dict[str, Any]]:
|
||||
if entity_type is None:
|
||||
return [dict(item) for item in INITIATIVE_ARCHETYPES]
|
||||
return [dict(item) for item in INITIATIVE_ARCHETYPES if item["entity_type"] == entity_type]
|
||||
|
||||
|
||||
def list_archetypes(*, entity_type: Optional[str] = None) -> list[dict[str, Any]]:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
if entity_type is None:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT archetype_key AS key, entity_type, label, description, is_system
|
||||
FROM entity_archetypes
|
||||
ORDER BY entity_type ASC, archetype_key ASC
|
||||
"""
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT archetype_key AS key, entity_type, label, description, is_system
|
||||
FROM entity_archetypes
|
||||
WHERE entity_type = %s
|
||||
ORDER BY archetype_key ASC
|
||||
""",
|
||||
(entity_type,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
if rows:
|
||||
return [dict(row) for row in rows]
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
conn.close()
|
||||
return _list_archetypes_from_memory(entity_type=entity_type)
|
||||
|
||||
|
||||
def validate_archetype_key(*, entity_type: str, archetype_key: str) -> None:
|
||||
if (entity_type, archetype_key) not in _ARCHETYPE_INDEX:
|
||||
raise ValueError(f"Unbekannter Archetyp: {archetype_key}")
|
||||
|
|
|
|||
20
backend/entity_fields/__init__.py
Normal file
20
backend/entity_fields/__init__.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
from entity_fields.definitions import FIELD_DEFINITIONS
|
||||
from entity_fields.service import (
|
||||
get_entity_field_values,
|
||||
get_initiative_archetype_key,
|
||||
list_field_definitions,
|
||||
patch_entity_field_values,
|
||||
)
|
||||
from entity_fields.sync import sync_entity_field_registry_to_db
|
||||
from entity_fields.validation import normalize_field_value, validate_required_fields
|
||||
|
||||
__all__ = [
|
||||
"FIELD_DEFINITIONS",
|
||||
"get_entity_field_values",
|
||||
"get_initiative_archetype_key",
|
||||
"list_field_definitions",
|
||||
"normalize_field_value",
|
||||
"patch_entity_field_values",
|
||||
"sync_entity_field_registry_to_db",
|
||||
"validate_required_fields",
|
||||
]
|
||||
48
backend/entity_fields/definitions.py
Normal file
48
backend/entity_fields/definitions.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""System-Felddefinitionen (Seed) — AP1.10b."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
FIELD_DEFINITIONS: tuple[dict[str, Any], ...] = (
|
||||
{
|
||||
"archetype_key": "initiative.program",
|
||||
"field_key": "stakeholder_map",
|
||||
"field_type": "longtext",
|
||||
"label": "Stakeholder-Übersicht",
|
||||
"required": False,
|
||||
"searchable": True,
|
||||
"sort_order": 10,
|
||||
"validation_json": {},
|
||||
},
|
||||
{
|
||||
"archetype_key": "initiative.program",
|
||||
"field_key": "success_criteria",
|
||||
"field_type": "longtext",
|
||||
"label": "Erfolgskriterien",
|
||||
"required": False,
|
||||
"searchable": True,
|
||||
"sort_order": 20,
|
||||
"validation_json": {},
|
||||
},
|
||||
{
|
||||
"archetype_key": "initiative.product",
|
||||
"field_key": "release_theme",
|
||||
"field_type": "text",
|
||||
"label": "Release-Thema",
|
||||
"required": False,
|
||||
"searchable": True,
|
||||
"sort_order": 10,
|
||||
"validation_json": {},
|
||||
},
|
||||
{
|
||||
"archetype_key": "initiative.product",
|
||||
"field_key": "metrics",
|
||||
"field_type": "longtext",
|
||||
"label": "Metriken / KPIs",
|
||||
"required": False,
|
||||
"searchable": True,
|
||||
"sort_order": 20,
|
||||
"validation_json": {},
|
||||
},
|
||||
)
|
||||
206
backend/entity_fields/service.py
Normal file
206
backend/entity_fields/service.py
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
"""Entity Field System — Lesen/Schreiben dynamischer Feldwerte — AP1.10b/10c."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from psycopg2.extras import Json, RealDictCursor
|
||||
|
||||
from db import get_connection
|
||||
from entity_fields.validation import normalize_field_value, validate_required_fields
|
||||
|
||||
|
||||
def _serialize_definition(row: dict[str, Any]) -> dict[str, Any]:
|
||||
result = dict(row)
|
||||
if result.get("id"):
|
||||
result["id"] = str(result["id"])
|
||||
validation = result.get("validation_json")
|
||||
if validation is None:
|
||||
result["validation_json"] = {}
|
||||
return result
|
||||
|
||||
|
||||
def _api_value_from_row(row: dict[str, Any], field_type: str) -> Any:
|
||||
if field_type == "number":
|
||||
if row.get("value_number") is not None:
|
||||
return float(row["value_number"])
|
||||
return None
|
||||
if field_type == "boolean":
|
||||
if row.get("value_boolean") is not None:
|
||||
return bool(row["value_boolean"])
|
||||
return False
|
||||
if field_type == "date":
|
||||
value_date = row.get("value_date")
|
||||
if value_date is not None:
|
||||
return value_date.isoformat()
|
||||
return None
|
||||
text = row.get("value_text") or ""
|
||||
return text
|
||||
|
||||
|
||||
def list_field_definitions(
|
||||
*,
|
||||
archetype_key: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, archetype_key, field_key, field_type, label,
|
||||
required, searchable, sort_order, validation_json
|
||||
FROM field_definitions
|
||||
WHERE archetype_key = %s
|
||||
ORDER BY sort_order ASC, field_key ASC
|
||||
""",
|
||||
(archetype_key,),
|
||||
)
|
||||
return [_serialize_definition(dict(row)) for row in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_entity_field_values(
|
||||
*,
|
||||
tenant_id: str,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
definitions: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
if not definitions:
|
||||
return {}
|
||||
|
||||
field_keys = [item["field_key"] for item in definitions]
|
||||
type_by_key = {item["field_key"]: item["field_type"] for item in definitions}
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT field_key, value_text, value_number, value_boolean, value_date
|
||||
FROM field_values
|
||||
WHERE tenant_id = %s
|
||||
AND entity_type = %s
|
||||
AND entity_id = %s
|
||||
AND field_key = ANY(%s)
|
||||
""",
|
||||
(tenant_id, entity_type, entity_id, field_keys),
|
||||
)
|
||||
rows = {row["field_key"]: dict(row) for row in cur.fetchall()}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
result: dict[str, Any] = {}
|
||||
for field_key in field_keys:
|
||||
row = rows.get(field_key)
|
||||
if row is None:
|
||||
field_type = type_by_key[field_key]
|
||||
if field_type == "boolean":
|
||||
result[field_key] = False
|
||||
else:
|
||||
result[field_key] = None if field_type in {"number", "date"} else ""
|
||||
else:
|
||||
result[field_key] = _api_value_from_row(row, type_by_key[field_key])
|
||||
return result
|
||||
|
||||
|
||||
def patch_entity_field_values(
|
||||
*,
|
||||
tenant_id: str,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
definitions: list[dict[str, Any]],
|
||||
values: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if not values:
|
||||
return get_entity_field_values(
|
||||
tenant_id=tenant_id,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
definitions=definitions,
|
||||
)
|
||||
|
||||
allowed_keys = {item["field_key"] for item in definitions}
|
||||
unknown = sorted(set(values.keys()) - allowed_keys)
|
||||
if unknown:
|
||||
raise ValueError(f"Unbekannte Felder: {', '.join(unknown)}")
|
||||
|
||||
definition_by_key = {item["field_key"]: item for item in definitions}
|
||||
merged = get_entity_field_values(
|
||||
tenant_id=tenant_id,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
definitions=definitions,
|
||||
)
|
||||
merged.update(values)
|
||||
validate_required_fields(definitions=definitions, values=merged)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
for field_key, raw_value in values.items():
|
||||
definition = definition_by_key[field_key]
|
||||
normalized = normalize_field_value(
|
||||
field_type=definition["field_type"],
|
||||
raw_value=raw_value,
|
||||
validation_json=definition.get("validation_json"),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO field_values (
|
||||
tenant_id, entity_type, entity_id, field_key,
|
||||
value_json, value_text, value_number, value_boolean, value_date
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (tenant_id, entity_type, entity_id, field_key) DO UPDATE SET
|
||||
value_json = EXCLUDED.value_json,
|
||||
value_text = EXCLUDED.value_text,
|
||||
value_number = EXCLUDED.value_number,
|
||||
value_boolean = EXCLUDED.value_boolean,
|
||||
value_date = EXCLUDED.value_date,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
(
|
||||
tenant_id,
|
||||
entity_type,
|
||||
entity_id,
|
||||
field_key,
|
||||
Json(normalized["value_json"]) if normalized["value_json"] is not None else None,
|
||||
normalized["value_text"],
|
||||
normalized["value_number"],
|
||||
normalized["value_boolean"],
|
||||
normalized["value_date"],
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return get_entity_field_values(
|
||||
tenant_id=tenant_id,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
definitions=definitions,
|
||||
)
|
||||
|
||||
|
||||
def get_initiative_archetype_key(*, tenant_id: str, initiative_id: str) -> Optional[str]:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT archetype_key
|
||||
FROM initiatives
|
||||
WHERE tenant_id = %s AND id = %s
|
||||
""",
|
||||
(tenant_id, initiative_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return row[0] if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
78
backend/entity_fields/sync.py
Normal file
78
backend/entity_fields/sync.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""Sync Entity-Archetypen und Felddefinitionen in PostgreSQL — AP1.10b."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from db import get_connection
|
||||
from entity_archetypes.registry import INITIATIVE_ARCHETYPES
|
||||
from entity_fields.definitions import FIELD_DEFINITIONS
|
||||
|
||||
|
||||
def sync_entity_field_registry_to_db() -> int:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
for archetype in INITIATIVE_ARCHETYPES:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO entity_archetypes (
|
||||
archetype_key, entity_type, label, description, is_system
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
ON CONFLICT (archetype_key) DO UPDATE SET
|
||||
entity_type = EXCLUDED.entity_type,
|
||||
label = EXCLUDED.label,
|
||||
description = EXCLUDED.description,
|
||||
is_system = EXCLUDED.is_system,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
(
|
||||
archetype["key"],
|
||||
archetype["entity_type"],
|
||||
archetype["label"],
|
||||
archetype["description"],
|
||||
archetype["is_system"],
|
||||
),
|
||||
)
|
||||
|
||||
for field_def in FIELD_DEFINITIONS:
|
||||
validation_json = field_def.get("validation_json") or {}
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO field_definitions (
|
||||
archetype_key, field_key, field_type, label,
|
||||
required, searchable, sort_order, validation_json
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
|
||||
ON CONFLICT (archetype_key, field_key) DO UPDATE SET
|
||||
field_type = EXCLUDED.field_type,
|
||||
label = EXCLUDED.label,
|
||||
required = EXCLUDED.required,
|
||||
searchable = EXCLUDED.searchable,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
validation_json = EXCLUDED.validation_json,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
(
|
||||
field_def["archetype_key"],
|
||||
field_def["field_key"],
|
||||
field_def["field_type"],
|
||||
field_def["label"],
|
||||
field_def["required"],
|
||||
field_def["searchable"],
|
||||
field_def["sort_order"],
|
||||
json.dumps(validation_json),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
print(
|
||||
"[entity_fields] Sync OK — "
|
||||
f"{len(INITIATIVE_ARCHETYPES)} Archetyp(en), "
|
||||
f"{len(FIELD_DEFINITIONS)} Felddefinition(en)"
|
||||
)
|
||||
return 0
|
||||
except Exception as exc:
|
||||
conn.rollback()
|
||||
print(f"[entity_fields] Sync FAIL: {exc}")
|
||||
return 1
|
||||
153
backend/entity_fields/validation.py
Normal file
153
backend/entity_fields/validation.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"""Validierung dynamischer Feldwerte gegen Definitionen — AP1.10b/10c."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
VALID_FIELD_TYPES = frozenset(
|
||||
{"text", "longtext", "number", "boolean", "date", "enum", "actor_ref", "url"}
|
||||
)
|
||||
|
||||
_URL_SCHEME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*:")
|
||||
|
||||
|
||||
def _parse_date(value: str) -> date:
|
||||
try:
|
||||
return date.fromisoformat(value)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Ungültiges Datum: {value}") from exc
|
||||
|
||||
|
||||
def _validate_url(value: str) -> None:
|
||||
if not value.strip():
|
||||
return
|
||||
parsed = urlparse(value.strip())
|
||||
if parsed.scheme and parsed.netloc:
|
||||
return
|
||||
if _URL_SCHEME_RE.match(value.strip()):
|
||||
return
|
||||
raise ValueError(f"Ungültige URL: {value}")
|
||||
|
||||
|
||||
def normalize_field_value(
|
||||
*,
|
||||
field_type: str,
|
||||
raw_value: Any,
|
||||
validation_json: Optional[dict[str, Any]] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Wert normalisieren und in DB-Spalten aufteilen."""
|
||||
validation_json = validation_json or {}
|
||||
|
||||
if field_type in {"text", "longtext", "url", "actor_ref", "enum"}:
|
||||
if raw_value is None:
|
||||
text = ""
|
||||
elif not isinstance(raw_value, str):
|
||||
raise ValueError(f"Feld erwartet Text, erhalten: {type(raw_value).__name__}")
|
||||
else:
|
||||
text = raw_value.strip()
|
||||
if field_type == "url" and text:
|
||||
_validate_url(text)
|
||||
if field_type == "enum" and text:
|
||||
options = validation_json.get("options") or []
|
||||
if options and text not in options:
|
||||
raise ValueError(f"Ungültiger Enum-Wert: {text}")
|
||||
return {
|
||||
"value_json": None,
|
||||
"value_text": text,
|
||||
"value_number": None,
|
||||
"value_boolean": None,
|
||||
"value_date": None,
|
||||
"api_value": text,
|
||||
}
|
||||
|
||||
if field_type == "number":
|
||||
if raw_value is None or raw_value == "":
|
||||
return {
|
||||
"value_json": None,
|
||||
"value_text": "",
|
||||
"value_number": None,
|
||||
"value_boolean": None,
|
||||
"value_date": None,
|
||||
"api_value": None,
|
||||
}
|
||||
try:
|
||||
number = float(raw_value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"Ungültige Zahl: {raw_value}") from exc
|
||||
return {
|
||||
"value_json": None,
|
||||
"value_text": str(number),
|
||||
"value_number": number,
|
||||
"value_boolean": None,
|
||||
"value_date": None,
|
||||
"api_value": number,
|
||||
}
|
||||
|
||||
if field_type == "boolean":
|
||||
if raw_value is None:
|
||||
bool_val = False
|
||||
elif isinstance(raw_value, bool):
|
||||
bool_val = raw_value
|
||||
elif isinstance(raw_value, str):
|
||||
lowered = raw_value.strip().lower()
|
||||
if lowered in {"true", "1", "yes", "on"}:
|
||||
bool_val = True
|
||||
elif lowered in {"false", "0", "no", "off", ""}:
|
||||
bool_val = False
|
||||
else:
|
||||
raise ValueError(f"Ungültiger Boolean-Wert: {raw_value}")
|
||||
else:
|
||||
bool_val = bool(raw_value)
|
||||
return {
|
||||
"value_json": None,
|
||||
"value_text": "true" if bool_val else "false",
|
||||
"value_number": None,
|
||||
"value_boolean": bool_val,
|
||||
"value_date": None,
|
||||
"api_value": bool_val,
|
||||
}
|
||||
|
||||
if field_type == "date":
|
||||
if raw_value is None or raw_value == "":
|
||||
return {
|
||||
"value_json": None,
|
||||
"value_text": "",
|
||||
"value_number": None,
|
||||
"value_boolean": None,
|
||||
"value_date": None,
|
||||
"api_value": None,
|
||||
}
|
||||
if isinstance(raw_value, date) and not isinstance(raw_value, datetime):
|
||||
parsed = raw_value
|
||||
elif isinstance(raw_value, str):
|
||||
parsed = _parse_date(raw_value)
|
||||
else:
|
||||
raise ValueError(f"Ungültiges Datum: {raw_value}")
|
||||
iso = parsed.isoformat()
|
||||
return {
|
||||
"value_json": None,
|
||||
"value_text": iso,
|
||||
"value_number": None,
|
||||
"value_boolean": None,
|
||||
"value_date": parsed,
|
||||
"api_value": iso,
|
||||
}
|
||||
|
||||
raise ValueError(f"Unbekannter Feldtyp: {field_type}")
|
||||
|
||||
|
||||
def validate_required_fields(
|
||||
*,
|
||||
definitions: list[dict[str, Any]],
|
||||
values: dict[str, Any],
|
||||
) -> None:
|
||||
for definition in definitions:
|
||||
if not definition.get("required"):
|
||||
continue
|
||||
key = definition["field_key"]
|
||||
raw = values.get(key)
|
||||
if raw is None or (isinstance(raw, str) and not raw.strip()):
|
||||
raise ValueError(f"Pflichtfeld fehlt: {definition.get('label') or key}")
|
||||
|
|
@ -23,8 +23,9 @@ fi
|
|||
|
||||
if [ "${SKIP_REGISTRY_SYNC}" != "1" ] && [ "${SKIP_REGISTRY_SYNC}" != "true" ] && [ "${SKIP_REGISTRY_SYNC}" != "yes" ]; then
|
||||
python sync_prompt_feature_config.py
|
||||
python sync_entity_field_registry.py
|
||||
else
|
||||
echo "[SKIP_REGISTRY_SYNC] Feature/Prompt/Config-Sync übersprungen"
|
||||
echo "[SKIP_REGISTRY_SYNC] Feature/Prompt/Config/EFS-Sync übersprungen"
|
||||
fi
|
||||
|
||||
export KAIRO_DB_READY=1
|
||||
|
|
|
|||
59
backend/migrations/017_entity_field_system.sql
Normal file
59
backend/migrations/017_entity_field_system.sql
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
-- AP1.10b: Entity Field System (EFS) — Archetypen, Felddefinitionen, Werte
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entity_archetypes (
|
||||
archetype_key VARCHAR(64) PRIMARY KEY,
|
||||
entity_type VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
is_system BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_entity_archetypes_entity_type
|
||||
ON entity_archetypes (entity_type);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS field_definitions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
archetype_key VARCHAR(64) NOT NULL
|
||||
REFERENCES entity_archetypes (archetype_key) ON DELETE CASCADE,
|
||||
field_key VARCHAR(64) NOT NULL,
|
||||
field_type VARCHAR(32) NOT NULL
|
||||
CHECK (field_type IN (
|
||||
'text', 'longtext', 'number', 'boolean', 'date', 'enum', 'actor_ref', 'url'
|
||||
)),
|
||||
label VARCHAR(255) NOT NULL,
|
||||
required BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
searchable BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
validation_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (archetype_key, field_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_field_definitions_archetype
|
||||
ON field_definitions (archetype_key, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS field_values (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants (id) ON DELETE CASCADE,
|
||||
entity_type VARCHAR(64) NOT NULL,
|
||||
entity_id UUID NOT NULL,
|
||||
field_key VARCHAR(64) NOT NULL,
|
||||
value_json JSONB,
|
||||
value_text TEXT NOT NULL DEFAULT '',
|
||||
value_number NUMERIC,
|
||||
value_boolean BOOLEAN,
|
||||
value_date DATE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (tenant_id, entity_type, entity_id, field_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_field_values_entity
|
||||
ON field_values (tenant_id, entity_type, entity_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_field_values_search
|
||||
ON field_values (tenant_id, entity_type, field_key)
|
||||
WHERE value_text <> '';
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
"""Entity-Archetypen API — AP1.10a (In-Memory-Registry bis 10b)."""
|
||||
"""Entity-Archetypen API — AP1.10a/10b."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -6,7 +6,8 @@ from typing import Optional
|
|||
|
||||
from capabilities import require_capability
|
||||
from entity_archetypes import list_archetypes
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from entity_fields.service import list_field_definitions
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from tenant_context import TenantContext
|
||||
|
||||
router = APIRouter(prefix="/api/entity-archetypes", tags=["entity-archetypes"])
|
||||
|
|
@ -19,3 +20,16 @@ def get_entity_archetypes(
|
|||
):
|
||||
_ = ctx
|
||||
return list_archetypes(entity_type=entity_type)
|
||||
|
||||
|
||||
@router.get("/field-definitions")
|
||||
def get_archetype_field_definitions(
|
||||
entity_type: str = Query(..., min_length=1),
|
||||
archetype_key: str = Query(..., min_length=1),
|
||||
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
|
||||
):
|
||||
_ = ctx
|
||||
archetypes = list_archetypes(entity_type=entity_type)
|
||||
if not any(item["key"] == archetype_key for item in archetypes):
|
||||
raise HTTPException(status_code=404, detail="Archetyp nicht gefunden")
|
||||
return list_field_definitions(archetype_key=archetype_key)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@ from typing import Literal, Optional
|
|||
|
||||
from capabilities import require_capability
|
||||
from data_layer import initiative_snapshot as dl_initiative_snapshot
|
||||
from entity_fields.service import (
|
||||
get_entity_field_values,
|
||||
list_field_definitions,
|
||||
patch_entity_field_values,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from steering.context import get_steering_context_dto
|
||||
|
|
@ -46,6 +51,10 @@ class InitiativeUpdateRequest(BaseModel):
|
|||
owner_actor_id: Optional[str] = None
|
||||
|
||||
|
||||
class InitiativeFieldsPatchRequest(BaseModel):
|
||||
fields: dict[str, object] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ActionCreateRequest(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
description: str = ""
|
||||
|
|
@ -194,6 +203,59 @@ def get_initiative_steering_context(
|
|||
return get_steering_context_dto(ctx, initiative_id=initiative_id)
|
||||
|
||||
|
||||
def _require_initiative(*, tenant_id: str, initiative_id: str) -> dict:
|
||||
initiative = initiative_service.get_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
if not initiative:
|
||||
raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden")
|
||||
return initiative
|
||||
|
||||
|
||||
@router.get("/{initiative_id}/field-definitions")
|
||||
def get_initiative_field_definitions(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
|
||||
):
|
||||
initiative = _require_initiative(tenant_id=ctx.tenant_id, initiative_id=initiative_id)
|
||||
return list_field_definitions(archetype_key=initiative["archetype_key"])
|
||||
|
||||
|
||||
@router.get("/{initiative_id}/fields")
|
||||
def get_initiative_fields(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
|
||||
):
|
||||
initiative = _require_initiative(tenant_id=ctx.tenant_id, initiative_id=initiative_id)
|
||||
definitions = list_field_definitions(archetype_key=initiative["archetype_key"])
|
||||
return get_entity_field_values(
|
||||
tenant_id=ctx.tenant_id,
|
||||
entity_type="initiative",
|
||||
entity_id=initiative_id,
|
||||
definitions=definitions,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{initiative_id}/fields")
|
||||
def patch_initiative_fields(
|
||||
initiative_id: str,
|
||||
body: InitiativeFieldsPatchRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.initiative.manage")),
|
||||
):
|
||||
initiative = _require_initiative(tenant_id=ctx.tenant_id, initiative_id=initiative_id)
|
||||
definitions = list_field_definitions(archetype_key=initiative["archetype_key"])
|
||||
try:
|
||||
return patch_entity_field_values(
|
||||
tenant_id=ctx.tenant_id,
|
||||
entity_type="initiative",
|
||||
entity_id=initiative_id,
|
||||
definitions=definitions,
|
||||
values=body.fields,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.patch("/{initiative_id}")
|
||||
def update_initiative(
|
||||
initiative_id: str,
|
||||
|
|
|
|||
15
backend/sync_entity_field_registry.py
Normal file
15
backend/sync_entity_field_registry.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
"""Startup sync for Entity Field System registry — AP1.10b."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from entity_fields.sync import sync_entity_field_registry_to_db
|
||||
|
||||
|
||||
def main() -> int:
|
||||
return sync_entity_field_registry_to_db()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -85,6 +85,13 @@ def _sync_prompt_feature_config():
|
|||
assert sync_registries() == 0
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _sync_entity_field_registry():
|
||||
from sync_entity_field_registry import main as sync_efs
|
||||
|
||||
assert sync_efs() == 0
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
import importlib
|
||||
|
|
|
|||
116
backend/tests/test_ap110_entity_fields.py
Normal file
116
backend/tests/test_ap110_entity_fields.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"""Entity Field System (AP1.10b/10c)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.factories import provision_user_in_tenant
|
||||
from tests.test_initiatives_actions import _auth, _create_initiative, _login
|
||||
|
||||
|
||||
def test_entity_field_registry_sync():
|
||||
from sync_entity_field_registry import main
|
||||
|
||||
assert main() == 0
|
||||
|
||||
|
||||
def test_program_field_definitions(client):
|
||||
user = provision_user_in_tenant(tenant_role="member")
|
||||
token = _login(client, user)
|
||||
|
||||
res = client.get(
|
||||
"/api/entity-archetypes/field-definitions"
|
||||
"?entity_type=initiative&archetype_key=initiative.program",
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert res.status_code == 200
|
||||
keys = {item["field_key"] for item in res.json()}
|
||||
assert keys == {"stakeholder_map", "success_criteria"}
|
||||
|
||||
|
||||
def test_initiative_dynamic_fields_roundtrip(client):
|
||||
user = provision_user_in_tenant(tenant_role="member")
|
||||
token = _login(client, user)
|
||||
|
||||
created = _create_initiative(
|
||||
client,
|
||||
token,
|
||||
title="Programm Nord",
|
||||
archetype_key="initiative.program",
|
||||
)
|
||||
assert created.status_code == 201
|
||||
initiative_id = created.json()["id"]
|
||||
|
||||
defs_res = client.get(
|
||||
f"/api/initiatives/{initiative_id}/field-definitions",
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert defs_res.status_code == 200
|
||||
assert len(defs_res.json()) == 2
|
||||
|
||||
empty = client.get(
|
||||
f"/api/initiatives/{initiative_id}/fields",
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert empty.status_code == 200
|
||||
assert empty.json()["stakeholder_map"] == ""
|
||||
|
||||
patched = client.patch(
|
||||
f"/api/initiatives/{initiative_id}/fields",
|
||||
json={
|
||||
"fields": {
|
||||
"stakeholder_map": "PO: Anna\nTech: Bob",
|
||||
"success_criteria": "Go-Live Q3",
|
||||
}
|
||||
},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert patched.status_code == 200
|
||||
body = patched.json()
|
||||
assert body["stakeholder_map"] == "PO: Anna\nTech: Bob"
|
||||
assert body["success_criteria"] == "Go-Live Q3"
|
||||
|
||||
|
||||
def test_unknown_dynamic_field_rejected(client):
|
||||
user = provision_user_in_tenant(tenant_role="member")
|
||||
token = _login(client, user)
|
||||
|
||||
created = _create_initiative(
|
||||
client,
|
||||
token,
|
||||
title="Produkt Beta",
|
||||
archetype_key="initiative.product",
|
||||
)
|
||||
initiative_id = created.json()["id"]
|
||||
|
||||
res = client.patch(
|
||||
f"/api/initiatives/{initiative_id}/fields",
|
||||
json={"fields": {"unknown_field": "x"}},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
def test_generic_initiative_has_no_dynamic_fields(client):
|
||||
user = provision_user_in_tenant(tenant_role="member")
|
||||
token = _login(client, user)
|
||||
|
||||
created = _create_initiative(
|
||||
client,
|
||||
token,
|
||||
title="Generic",
|
||||
archetype_key="initiative.generic",
|
||||
)
|
||||
initiative_id = created.json()["id"]
|
||||
|
||||
defs_res = client.get(
|
||||
f"/api/initiatives/{initiative_id}/field-definitions",
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert defs_res.status_code == 200
|
||||
assert defs_res.json() == []
|
||||
|
||||
fields_res = client.get(
|
||||
f"/api/initiatives/{initiative_id}/fields",
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert fields_res.status_code == 200
|
||||
assert fields_res.json() == {}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
APP_VERSION = "0.17.0-ap1.10a"
|
||||
DB_SCHEMA_VERSION = "016"
|
||||
APP_VERSION = "0.17.0-ap1.10c"
|
||||
DB_SCHEMA_VERSION = "017"
|
||||
APP_NAME = "jinkendo-kairo"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "kairo-jinkendo-frontend",
|
||||
"version": "0.13.0-ap1.4",
|
||||
"version": "0.17.0-ap1.10c",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
|
|||
36
frontend/src/api/entityFields.js
Normal file
36
frontend/src/api/entityFields.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { apiFetch } from './client.js'
|
||||
|
||||
export function listArchetypeFieldDefinitions(entityType, archetypeKey) {
|
||||
const params = new URLSearchParams({
|
||||
entity_type: entityType,
|
||||
archetype_key: archetypeKey,
|
||||
})
|
||||
return apiFetch(`/api/entity-archetypes/field-definitions?${params}`)
|
||||
}
|
||||
|
||||
export function getInitiativeFieldDefinitions(initiativeId) {
|
||||
return apiFetch(`/api/initiatives/${initiativeId}/field-definitions`)
|
||||
}
|
||||
|
||||
export function getInitiativeFields(initiativeId) {
|
||||
return apiFetch(`/api/initiatives/${initiativeId}/fields`)
|
||||
}
|
||||
|
||||
export function patchInitiativeFields(initiativeId, fields) {
|
||||
return apiFetch(`/api/initiatives/${initiativeId}/fields`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ fields }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function saveInitiativeDynamicFields(initiativeId, dynamicFields) {
|
||||
if (!dynamicFields || Object.keys(dynamicFields).length === 0) {
|
||||
return
|
||||
}
|
||||
await patchInitiativeFields(initiativeId, dynamicFields)
|
||||
}
|
||||
|
||||
export function splitInitiativeFormPayload(payload) {
|
||||
const { dynamicFields, ...core } = payload
|
||||
return { core, dynamicFields: dynamicFields || {} }
|
||||
}
|
||||
104
frontend/src/components/FieldRenderer.jsx
Normal file
104
frontend/src/components/FieldRenderer.jsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
export function FieldRenderer({ definitions, values, onChange, disabled = false }) {
|
||||
if (!definitions?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<fieldset className="field-renderer">
|
||||
<legend className="field-renderer__legend">Zusatzfelder</legend>
|
||||
{definitions.map((def) => (
|
||||
<DynamicField
|
||||
key={def.field_key}
|
||||
definition={def}
|
||||
value={values?.[def.field_key]}
|
||||
onChange={(next) => onChange(def.field_key, next)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
|
||||
function DynamicField({ definition, value, onChange, disabled }) {
|
||||
const { field_key, field_type, label, required, validation_json: validation } = definition
|
||||
const inputId = `field-${field_key}`
|
||||
|
||||
if (field_type === 'longtext') {
|
||||
return (
|
||||
<label htmlFor={inputId}>
|
||||
{label}
|
||||
{required && ' *'}
|
||||
<textarea
|
||||
id={inputId}
|
||||
name={field_key}
|
||||
rows={4}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
if (field_type === 'boolean') {
|
||||
return (
|
||||
<label className="checkbox-label" htmlFor={inputId}>
|
||||
<input
|
||||
id={inputId}
|
||||
name={field_key}
|
||||
type="checkbox"
|
||||
checked={Boolean(value)}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{label}
|
||||
{required && ' *'}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
if (field_type === 'enum') {
|
||||
const options = validation?.options || []
|
||||
return (
|
||||
<label htmlFor={inputId}>
|
||||
{label}
|
||||
{required && ' *'}
|
||||
<select
|
||||
id={inputId}
|
||||
name={field_key}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{options.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
const inputType =
|
||||
field_type === 'number' ? 'number' : field_type === 'date' ? 'date' : field_type === 'url' ? 'url' : 'text'
|
||||
|
||||
return (
|
||||
<label htmlFor={inputId}>
|
||||
{label}
|
||||
{required && ' *'}
|
||||
<input
|
||||
id={inputId}
|
||||
name={field_key}
|
||||
type={inputType}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(field_type === 'number' ? e.target.value : e.target.value)}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,15 +3,26 @@ import { INITIATIVE_STATUSES, PRIORITIES } from '../constants/status.js'
|
|||
import { INITIATIVE_STATUS_LABELS, PRIORITY_LABELS } from '../constants/status.js'
|
||||
import { INITIATIVE_ARCHETYPE_LABELS } from '../constants/initiativeArchetypes.js'
|
||||
import { listEntityArchetypes } from '../api/entityArchetypes.js'
|
||||
import {
|
||||
getInitiativeFieldDefinitions,
|
||||
getInitiativeFields,
|
||||
listArchetypeFieldDefinitions,
|
||||
} from '../api/entityFields.js'
|
||||
import { FieldRenderer } from './FieldRenderer.jsx'
|
||||
|
||||
export function InitiativeForm({
|
||||
initial = {},
|
||||
initiativeId = null,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
busy = false,
|
||||
submitLabel = 'Speichern',
|
||||
}) {
|
||||
const [archetypes, setArchetypes] = useState(null)
|
||||
const [archetypeKey, setArchetypeKey] = useState(initial.archetype_key || 'initiative.generic')
|
||||
const [fieldDefinitions, setFieldDefinitions] = useState([])
|
||||
const [dynamicValues, setDynamicValues] = useState({})
|
||||
const [fieldsLoading, setFieldsLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
|
@ -34,6 +45,40 @@ export function InitiativeForm({
|
|||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setFieldsLoading(true)
|
||||
|
||||
async function loadFields() {
|
||||
try {
|
||||
let definitions = []
|
||||
let values = {}
|
||||
if (initiativeId) {
|
||||
definitions = await getInitiativeFieldDefinitions(initiativeId)
|
||||
values = await getInitiativeFields(initiativeId)
|
||||
} else if (archetypeKey) {
|
||||
definitions = await listArchetypeFieldDefinitions('initiative', archetypeKey)
|
||||
}
|
||||
if (!cancelled) {
|
||||
setFieldDefinitions(definitions)
|
||||
setDynamicValues(values)
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setFieldDefinitions([])
|
||||
setDynamicValues({})
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setFieldsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadFields()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [initiativeId, archetypeKey])
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
const form = e.target
|
||||
|
|
@ -45,9 +90,16 @@ export function InitiativeForm({
|
|||
target_state_summary: form.target_state_summary.value,
|
||||
status: form.status.value,
|
||||
priority: form.priority.value,
|
||||
dynamicFields: dynamicValues,
|
||||
})
|
||||
}
|
||||
|
||||
function handleDynamicChange(fieldKey, value) {
|
||||
setDynamicValues((prev) => ({ ...prev, [fieldKey]: value }))
|
||||
}
|
||||
|
||||
const formReady = archetypes && !fieldsLoading
|
||||
|
||||
return (
|
||||
<form className="form workspace-form" onSubmit={handleSubmit}>
|
||||
<label>
|
||||
|
|
@ -58,7 +110,8 @@ export function InitiativeForm({
|
|||
Archetyp
|
||||
<select
|
||||
name="archetype_key"
|
||||
defaultValue={initial.archetype_key || 'initiative.generic'}
|
||||
value={archetypeKey}
|
||||
onChange={(e) => setArchetypeKey(e.target.value)}
|
||||
disabled={!archetypes}
|
||||
>
|
||||
{(archetypes || []).map((item) => (
|
||||
|
|
@ -84,6 +137,12 @@ export function InitiativeForm({
|
|||
defaultValue={initial.target_state_summary || ''}
|
||||
/>
|
||||
</label>
|
||||
<FieldRenderer
|
||||
definitions={fieldDefinitions}
|
||||
values={dynamicValues}
|
||||
onChange={handleDynamicChange}
|
||||
disabled={busy || fieldsLoading}
|
||||
/>
|
||||
<div className="form-row form-row--2">
|
||||
<label>
|
||||
Status
|
||||
|
|
@ -107,7 +166,7 @@ export function InitiativeForm({
|
|||
</label>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn btn-primary" disabled={busy || !archetypes}>
|
||||
<button type="submit" className="btn btn-primary" disabled={busy || !formReady}>
|
||||
{busy ? 'Speichern …' : submitLabel}
|
||||
</button>
|
||||
{onCancel && (
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
updateInitiative,
|
||||
} from '../api/initiatives.js'
|
||||
import { updateAction, setActionAssignments } from '../api/actions.js'
|
||||
import { saveInitiativeDynamicFields, splitInitiativeFormPayload } from '../api/entityFields.js'
|
||||
import {
|
||||
listInitiativeBlockers,
|
||||
createInitiativeBlocker,
|
||||
|
|
@ -312,8 +313,10 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
|
|||
|
||||
async function handleUpdateInitiative(body) {
|
||||
setFormBusy(true)
|
||||
const { core, dynamicFields } = splitInitiativeFormPayload(body)
|
||||
try {
|
||||
await updateInitiative(id, body)
|
||||
await updateInitiative(id, core)
|
||||
await saveInitiativeDynamicFields(id, dynamicFields)
|
||||
await load()
|
||||
return true
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { listInitiatives, listInitiativeActions, createInitiative } from '../api/initiatives.js'
|
||||
import { saveInitiativeDynamicFields, splitInitiativeFormPayload } from '../api/entityFields.js'
|
||||
import { countOpenActions } from '../api/actions.js'
|
||||
import { StatusBadge } from '../components/StatusBadge.jsx'
|
||||
import { PriorityBadge } from '../components/PriorityBadge.jsx'
|
||||
|
|
@ -55,8 +56,10 @@ export function InitiativesPage() {
|
|||
|
||||
async function handleCreate(payload) {
|
||||
setFormBusy(true)
|
||||
const { core, dynamicFields } = splitInitiativeFormPayload(payload)
|
||||
try {
|
||||
await createInitiative({ ...payload, owner_actor_id: context?.actor?.id })
|
||||
const created = await createInitiative({ ...core, owner_actor_id: context?.actor?.id })
|
||||
await saveInitiativeDynamicFields(created.id, dynamicFields)
|
||||
setShowForm(false)
|
||||
await load()
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { getWidgetsForArea } from '../registry/widgetRegistry.js'
|
|||
import { useSession } from '../context/SessionContext.jsx'
|
||||
import { InitiativeForm } from '../components/InitiativeForm.jsx'
|
||||
import { createInitiative } from '../api/initiatives.js'
|
||||
import { saveInitiativeDynamicFields, splitInitiativeFormPayload } from '../api/entityFields.js'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export function WorkspacePage() {
|
||||
|
|
@ -19,11 +20,13 @@ export function WorkspacePage() {
|
|||
async function handleCreateInitiative(payload) {
|
||||
setFormBusy(true)
|
||||
setFormError(null)
|
||||
const { core, dynamicFields } = splitInitiativeFormPayload(payload)
|
||||
try {
|
||||
await createInitiative({
|
||||
...payload,
|
||||
const created = await createInitiative({
|
||||
...core,
|
||||
owner_actor_id: context?.actor?.id,
|
||||
})
|
||||
await saveInitiativeDynamicFields(created.id, dynamicFields)
|
||||
setShowForm(false)
|
||||
navigate('/initiatives')
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { getWidgetsForArea } from '../../registry/widgetRegistry.js'
|
|||
import { useSession } from '../../context/SessionContext.jsx'
|
||||
import { InitiativeForm } from '../../components/InitiativeForm.jsx'
|
||||
import { createInitiative } from '../../api/initiatives.js'
|
||||
import { saveInitiativeDynamicFields, splitInitiativeFormPayload } from '../../api/entityFields.js'
|
||||
import { ModeShell } from '../../components/ModeShell.jsx'
|
||||
import { initiativeCatalogPath } from '../../utils/routes.js'
|
||||
|
||||
|
|
@ -21,11 +22,13 @@ export function CockpitPage() {
|
|||
async function handleCreateInitiative(payload) {
|
||||
setFormBusy(true)
|
||||
setFormError(null)
|
||||
const { core, dynamicFields } = splitInitiativeFormPayload(payload)
|
||||
try {
|
||||
await createInitiative({
|
||||
...payload,
|
||||
const created = await createInitiative({
|
||||
...core,
|
||||
owner_actor_id: context?.actor?.id,
|
||||
})
|
||||
await saveInitiativeDynamicFields(created.id, dynamicFields)
|
||||
setShowForm(false)
|
||||
navigate(initiativeCatalogPath())
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx'
|
||||
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
|
||||
import { StatusBadge } from '../../components/StatusBadge.jsx'
|
||||
|
|
@ -8,6 +8,68 @@ import { InitiativeForm } from '../../components/InitiativeForm.jsx'
|
|||
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
||||
import { useProgramScope } from '../../context/ProgramScopeContext.jsx'
|
||||
import { initiativeArchetypeLabel } from '../../constants/initiativeArchetypes.js'
|
||||
import {
|
||||
getInitiativeFieldDefinitions,
|
||||
getInitiativeFields,
|
||||
} from '../../api/entityFields.js'
|
||||
|
||||
function DynamicFieldsDisplay({ initiativeId }) {
|
||||
const [definitions, setDefinitions] = useState([])
|
||||
const [values, setValues] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function load() {
|
||||
try {
|
||||
const [defs, fieldValues] = await Promise.all([
|
||||
getInitiativeFieldDefinitions(initiativeId),
|
||||
getInitiativeFields(initiativeId),
|
||||
])
|
||||
if (!cancelled) {
|
||||
setDefinitions(defs)
|
||||
setValues(fieldValues)
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setDefinitions([])
|
||||
setValues({})
|
||||
}
|
||||
}
|
||||
}
|
||||
load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [initiativeId])
|
||||
|
||||
if (!definitions.length || values === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const visible = definitions.filter((def) => {
|
||||
const val = values[def.field_key]
|
||||
if (def.field_type === 'boolean') return val === true
|
||||
return val != null && String(val).trim() !== ''
|
||||
})
|
||||
|
||||
if (!visible.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return visible.map((def) => (
|
||||
<div key={def.field_key} className="plan-profile__field">
|
||||
<h3 className="plan-profile__label">{def.label}</h3>
|
||||
<p>{formatFieldValue(def, values[def.field_key])}</p>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
function formatFieldValue(definition, value) {
|
||||
if (definition.field_type === 'boolean') {
|
||||
return value ? 'Ja' : 'Nein'
|
||||
}
|
||||
return String(value ?? '')
|
||||
}
|
||||
|
||||
function PlanProfileInner() {
|
||||
const ops = useInitiativeOperations()
|
||||
|
|
@ -77,13 +139,12 @@ function PlanProfileInner() {
|
|||
<p>{initiative.target_state_summary}</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="muted plan-profile__note">
|
||||
Dynamische Zusatzfelder (EFS) folgen in AP1.10c.
|
||||
</p>
|
||||
<DynamicFieldsDisplay initiativeId={initiative.id} key={editing ? 'closed' : 'open'} />
|
||||
</section>
|
||||
|
||||
<Modal open={editing} title="Vorhaben bearbeiten" onClose={() => setEditing(false)} size="lg">
|
||||
<InitiativeForm
|
||||
initiativeId={initiative.id}
|
||||
initial={initiative}
|
||||
onSubmit={handleSave}
|
||||
onCancel={() => setEditing(false)}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user