AP1.10a: Initiative-Standardfelder vision, Zielzustand und Archetyp-Schlüssel.
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 1m51s
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 18s
Test Suite / playwright-smoke (push) Successful in 12s
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 1m51s
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 18s
Test Suite / playwright-smoke (push) Successful in 12s
Migration 016, Archetyp-Registry-API, erweitertes Profil-Modal und Plan-Profil-Ansicht. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
495993cad6
commit
408050bcb8
|
|
@ -21,8 +21,8 @@ def get_active_initiatives(
|
||||||
try:
|
try:
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
sql = """
|
sql = """
|
||||||
SELECT id, tenant_id, title, goal, status, priority,
|
SELECT id, tenant_id, title, goal, vision, target_state_summary, archetype_key,
|
||||||
owner_actor_id, created_at, updated_at
|
status, priority, owner_actor_id, created_at, updated_at
|
||||||
FROM initiatives
|
FROM initiatives
|
||||||
WHERE tenant_id = %s AND status IN ('active', 'paused')
|
WHERE tenant_id = %s AND status IN ('active', 'paused')
|
||||||
ORDER BY updated_at DESC, title
|
ORDER BY updated_at DESC, title
|
||||||
|
|
|
||||||
13
backend/entity_archetypes/__init__.py
Normal file
13
backend/entity_archetypes/__init__.py
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
from entity_archetypes.registry import (
|
||||||
|
INITIATIVE_ARCHETYPES,
|
||||||
|
archetype_label,
|
||||||
|
list_archetypes,
|
||||||
|
validate_archetype_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"INITIATIVE_ARCHETYPES",
|
||||||
|
"archetype_label",
|
||||||
|
"list_archetypes",
|
||||||
|
"validate_archetype_key",
|
||||||
|
]
|
||||||
49
backend/entity_archetypes/registry.py
Normal file
49
backend/entity_archetypes/registry.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""System-Archetypen (AP1.10a) — Seed-Daten bis EFS-Tabellen (10b)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
INITIATIVE_ARCHETYPES: tuple[dict[str, Any], ...] = (
|
||||||
|
{
|
||||||
|
"key": "initiative.generic",
|
||||||
|
"entity_type": "initiative",
|
||||||
|
"label": "Allgemeines Vorhaben",
|
||||||
|
"description": "Standard-Vorhaben ohne spezielle Vorlage.",
|
||||||
|
"is_system": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "initiative.program",
|
||||||
|
"entity_type": "initiative",
|
||||||
|
"label": "Programm / Mega-Vorhaben",
|
||||||
|
"description": "Mehrere Stränge, Zielzustand und Vision zentral.",
|
||||||
|
"is_system": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "initiative.product",
|
||||||
|
"entity_type": "initiative",
|
||||||
|
"label": "Produkt / Release",
|
||||||
|
"description": "Produkt- oder Release-orientiertes Vorhaben.",
|
||||||
|
"is_system": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
_ARCHETYPE_INDEX = {
|
||||||
|
(item["entity_type"], item["key"]): item for item in INITIATIVE_ARCHETYPES
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_archetypes(*, 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 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}")
|
||||||
|
|
||||||
|
|
||||||
|
def archetype_label(*, entity_type: str, archetype_key: str) -> Optional[str]:
|
||||||
|
item = _ARCHETYPE_INDEX.get((entity_type, archetype_key))
|
||||||
|
return item["label"] if item else None
|
||||||
|
|
@ -63,6 +63,7 @@ from routers import ( # noqa: E402
|
||||||
blockers,
|
blockers,
|
||||||
config,
|
config,
|
||||||
decisions,
|
decisions,
|
||||||
|
entity_archetypes,
|
||||||
evidence,
|
evidence,
|
||||||
features,
|
features,
|
||||||
initiatives,
|
initiatives,
|
||||||
|
|
@ -85,6 +86,7 @@ app.include_router(features.router)
|
||||||
app.include_router(prompts.router)
|
app.include_router(prompts.router)
|
||||||
app.include_router(config.router)
|
app.include_router(config.router)
|
||||||
app.include_router(initiatives.router)
|
app.include_router(initiatives.router)
|
||||||
|
app.include_router(entity_archetypes.router)
|
||||||
app.include_router(journey.initiative_router)
|
app.include_router(journey.initiative_router)
|
||||||
app.include_router(journey.roadmap_router)
|
app.include_router(journey.roadmap_router)
|
||||||
app.include_router(actions.router)
|
app.include_router(actions.router)
|
||||||
|
|
|
||||||
16
backend/migrations/016_initiative_archetypes.sql
Normal file
16
backend/migrations/016_initiative_archetypes.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
-- AP1.10a: Initiative Standardfelder + Archetyp-Schlüssel
|
||||||
|
|
||||||
|
ALTER TABLE initiatives
|
||||||
|
ADD COLUMN IF NOT EXISTS vision TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
ALTER TABLE initiatives
|
||||||
|
ADD COLUMN IF NOT EXISTS target_state_summary TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
ALTER TABLE initiatives
|
||||||
|
ADD COLUMN IF NOT EXISTS archetype_key VARCHAR(64) NOT NULL DEFAULT 'initiative.generic';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_initiatives_archetype
|
||||||
|
ON initiatives (tenant_id, archetype_key);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_initiatives_status_tenant
|
||||||
|
ON initiatives (tenant_id, status);
|
||||||
21
backend/routers/entity_archetypes.py
Normal file
21
backend/routers/entity_archetypes.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
"""Entity-Archetypen API — AP1.10a (In-Memory-Registry bis 10b)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from capabilities import require_capability
|
||||||
|
from entity_archetypes import list_archetypes
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/entity-archetypes", tags=["entity-archetypes"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def get_entity_archetypes(
|
||||||
|
entity_type: Optional[str] = Query(default=None),
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
|
||||||
|
):
|
||||||
|
_ = ctx
|
||||||
|
return list_archetypes(entity_type=entity_type)
|
||||||
|
|
@ -27,6 +27,9 @@ router = APIRouter(prefix="/api/initiatives", tags=["initiatives"])
|
||||||
class InitiativeCreateRequest(BaseModel):
|
class InitiativeCreateRequest(BaseModel):
|
||||||
title: str = Field(min_length=1, max_length=255)
|
title: str = Field(min_length=1, max_length=255)
|
||||||
goal: str = ""
|
goal: str = ""
|
||||||
|
vision: str = ""
|
||||||
|
target_state_summary: str = ""
|
||||||
|
archetype_key: str = "initiative.generic"
|
||||||
status: Literal["active", "paused", "completed", "archived"] = "active"
|
status: Literal["active", "paused", "completed", "archived"] = "active"
|
||||||
priority: Literal["low", "normal", "high"] = "normal"
|
priority: Literal["low", "normal", "high"] = "normal"
|
||||||
owner_actor_id: Optional[str] = None
|
owner_actor_id: Optional[str] = None
|
||||||
|
|
@ -35,6 +38,9 @@ class InitiativeCreateRequest(BaseModel):
|
||||||
class InitiativeUpdateRequest(BaseModel):
|
class InitiativeUpdateRequest(BaseModel):
|
||||||
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
||||||
goal: Optional[str] = None
|
goal: Optional[str] = None
|
||||||
|
vision: Optional[str] = None
|
||||||
|
target_state_summary: Optional[str] = None
|
||||||
|
archetype_key: Optional[str] = None
|
||||||
status: Optional[Literal["active", "paused", "completed", "archived"]] = None
|
status: Optional[Literal["active", "paused", "completed", "archived"]] = None
|
||||||
priority: Optional[Literal["low", "normal", "high"]] = None
|
priority: Optional[Literal["low", "normal", "high"]] = None
|
||||||
owner_actor_id: Optional[str] = None
|
owner_actor_id: Optional[str] = None
|
||||||
|
|
@ -138,6 +144,9 @@ def create_initiative(
|
||||||
tenant_id=ctx.tenant_id,
|
tenant_id=ctx.tenant_id,
|
||||||
title=body.title,
|
title=body.title,
|
||||||
goal=body.goal,
|
goal=body.goal,
|
||||||
|
vision=body.vision,
|
||||||
|
target_state_summary=body.target_state_summary,
|
||||||
|
archetype_key=body.archetype_key,
|
||||||
status=body.status,
|
status=body.status,
|
||||||
priority=body.priority,
|
priority=body.priority,
|
||||||
owner_actor_id=owner_actor_id,
|
owner_actor_id=owner_actor_id,
|
||||||
|
|
@ -198,6 +207,9 @@ def update_initiative(
|
||||||
user_id=ctx.user_id,
|
user_id=ctx.user_id,
|
||||||
title=body.title,
|
title=body.title,
|
||||||
goal=body.goal,
|
goal=body.goal,
|
||||||
|
vision=body.vision,
|
||||||
|
target_state_summary=body.target_state_summary,
|
||||||
|
archetype_key=body.archetype_key,
|
||||||
status=body.status,
|
status=body.status,
|
||||||
priority=body.priority,
|
priority=body.priority,
|
||||||
owner_actor_id=body.owner_actor_id,
|
owner_actor_id=body.owner_actor_id,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from typing import Any, Literal, Optional
|
||||||
from psycopg2.extras import RealDictCursor
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
from db import get_connection
|
from db import get_connection
|
||||||
|
from entity_archetypes import validate_archetype_key
|
||||||
from services.audit import log_audit
|
from services.audit import log_audit
|
||||||
|
|
||||||
InitiativeStatus = Literal["active", "paused", "completed", "archived"]
|
InitiativeStatus = Literal["active", "paused", "completed", "archived"]
|
||||||
|
|
@ -15,6 +16,11 @@ Priority = Literal["low", "normal", "high"]
|
||||||
INITIATIVE_STATUSES = frozenset({"active", "paused", "completed", "archived"})
|
INITIATIVE_STATUSES = frozenset({"active", "paused", "completed", "archived"})
|
||||||
PRIORITIES = frozenset({"low", "normal", "high"})
|
PRIORITIES = frozenset({"low", "normal", "high"})
|
||||||
|
|
||||||
|
_INITIATIVE_COLUMNS = """
|
||||||
|
id, tenant_id, title, goal, vision, target_state_summary, archetype_key,
|
||||||
|
status, priority, owner_actor_id, created_at, updated_at
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
result = dict(row)
|
result = dict(row)
|
||||||
|
|
@ -51,12 +57,19 @@ def _actor_in_tenant(*, tenant_id: str, actor_id: str) -> bool:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_archetype_key(archetype_key: str) -> None:
|
||||||
|
validate_archetype_key(entity_type="initiative", archetype_key=archetype_key)
|
||||||
|
|
||||||
|
|
||||||
def create_initiative(
|
def create_initiative(
|
||||||
*,
|
*,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
title: str,
|
title: str,
|
||||||
owner_actor_id: str,
|
owner_actor_id: str,
|
||||||
goal: str = "",
|
goal: str = "",
|
||||||
|
vision: str = "",
|
||||||
|
target_state_summary: str = "",
|
||||||
|
archetype_key: str = "initiative.generic",
|
||||||
status: InitiativeStatus = "active",
|
status: InitiativeStatus = "active",
|
||||||
priority: Priority = "normal",
|
priority: Priority = "normal",
|
||||||
user_id: Optional[str] = None,
|
user_id: Optional[str] = None,
|
||||||
|
|
@ -66,6 +79,7 @@ def create_initiative(
|
||||||
raise ValueError("Titel ist erforderlich")
|
raise ValueError("Titel ist erforderlich")
|
||||||
_validate_initiative_status(status)
|
_validate_initiative_status(status)
|
||||||
_validate_priority(priority)
|
_validate_priority(priority)
|
||||||
|
_validate_archetype_key(archetype_key)
|
||||||
if not _actor_in_tenant(tenant_id=tenant_id, actor_id=owner_actor_id):
|
if not _actor_in_tenant(tenant_id=tenant_id, actor_id=owner_actor_id):
|
||||||
raise ValueError("Owner-Actor gehört nicht zum Tenant")
|
raise ValueError("Owner-Actor gehört nicht zum Tenant")
|
||||||
|
|
||||||
|
|
@ -73,15 +87,25 @@ def create_initiative(
|
||||||
try:
|
try:
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
f"""
|
||||||
INSERT INTO initiatives (
|
INSERT INTO initiatives (
|
||||||
tenant_id, title, goal, status, priority, owner_actor_id
|
tenant_id, title, goal, vision, target_state_summary, archetype_key,
|
||||||
|
status, priority, owner_actor_id
|
||||||
)
|
)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
RETURNING id, tenant_id, title, goal, status, priority,
|
RETURNING {_INITIATIVE_COLUMNS}
|
||||||
owner_actor_id, created_at, updated_at
|
|
||||||
""",
|
""",
|
||||||
(tenant_id, title, goal, status, priority, owner_actor_id),
|
(
|
||||||
|
tenant_id,
|
||||||
|
title,
|
||||||
|
goal,
|
||||||
|
vision,
|
||||||
|
target_state_summary,
|
||||||
|
archetype_key,
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
owner_actor_id,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
row = _serialize_row(dict(cur.fetchone()))
|
row = _serialize_row(dict(cur.fetchone()))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
@ -109,9 +133,8 @@ def list_initiatives(*, tenant_id: str) -> list[dict[str, Any]]:
|
||||||
try:
|
try:
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
f"""
|
||||||
SELECT id, tenant_id, title, goal, status, priority,
|
SELECT {_INITIATIVE_COLUMNS}
|
||||||
owner_actor_id, created_at, updated_at
|
|
||||||
FROM initiatives
|
FROM initiatives
|
||||||
WHERE tenant_id = %s
|
WHERE tenant_id = %s
|
||||||
ORDER BY updated_at DESC, title
|
ORDER BY updated_at DESC, title
|
||||||
|
|
@ -128,9 +151,8 @@ def get_initiative(*, tenant_id: str, initiative_id: str) -> Optional[dict[str,
|
||||||
try:
|
try:
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
f"""
|
||||||
SELECT id, tenant_id, title, goal, status, priority,
|
SELECT {_INITIATIVE_COLUMNS}
|
||||||
owner_actor_id, created_at, updated_at
|
|
||||||
FROM initiatives
|
FROM initiatives
|
||||||
WHERE id = %s AND tenant_id = %s
|
WHERE id = %s AND tenant_id = %s
|
||||||
""",
|
""",
|
||||||
|
|
@ -149,6 +171,9 @@ def update_initiative(
|
||||||
user_id: Optional[str] = None,
|
user_id: Optional[str] = None,
|
||||||
title: Optional[str] = None,
|
title: Optional[str] = None,
|
||||||
goal: Optional[str] = None,
|
goal: Optional[str] = None,
|
||||||
|
vision: Optional[str] = None,
|
||||||
|
target_state_summary: Optional[str] = None,
|
||||||
|
archetype_key: Optional[str] = None,
|
||||||
status: Optional[InitiativeStatus] = None,
|
status: Optional[InitiativeStatus] = None,
|
||||||
priority: Optional[Priority] = None,
|
priority: Optional[Priority] = None,
|
||||||
owner_actor_id: Optional[str] = None,
|
owner_actor_id: Optional[str] = None,
|
||||||
|
|
@ -170,6 +195,16 @@ def update_initiative(
|
||||||
if goal is not None:
|
if goal is not None:
|
||||||
updates.append("goal = %s")
|
updates.append("goal = %s")
|
||||||
params.append(goal)
|
params.append(goal)
|
||||||
|
if vision is not None:
|
||||||
|
updates.append("vision = %s")
|
||||||
|
params.append(vision)
|
||||||
|
if target_state_summary is not None:
|
||||||
|
updates.append("target_state_summary = %s")
|
||||||
|
params.append(target_state_summary)
|
||||||
|
if archetype_key is not None:
|
||||||
|
_validate_archetype_key(archetype_key)
|
||||||
|
updates.append("archetype_key = %s")
|
||||||
|
params.append(archetype_key)
|
||||||
if status is not None:
|
if status is not None:
|
||||||
_validate_initiative_status(status)
|
_validate_initiative_status(status)
|
||||||
updates.append("status = %s")
|
updates.append("status = %s")
|
||||||
|
|
@ -198,8 +233,7 @@ def update_initiative(
|
||||||
UPDATE initiatives
|
UPDATE initiatives
|
||||||
SET {", ".join(updates)}
|
SET {", ".join(updates)}
|
||||||
WHERE id = %s AND tenant_id = %s
|
WHERE id = %s AND tenant_id = %s
|
||||||
RETURNING id, tenant_id, title, goal, status, priority,
|
RETURNING {_INITIATIVE_COLUMNS}
|
||||||
owner_actor_id, created_at, updated_at
|
|
||||||
""",
|
""",
|
||||||
params,
|
params,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
65
backend/tests/test_ap110_initiative_archetypes.py
Normal file
65
backend/tests/test_ap110_initiative_archetypes.py
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
"""Initiative-Archetypen & Standardfelder (AP1.10a)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from auth import AUTH_HEADER
|
||||||
|
from tests.factories import provision_user_in_tenant
|
||||||
|
from tests.test_initiatives_actions import _auth, _create_initiative, _login
|
||||||
|
|
||||||
|
|
||||||
|
def test_initiative_archetype_fields(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="member")
|
||||||
|
token = _login(client, user)
|
||||||
|
|
||||||
|
created = _create_initiative(
|
||||||
|
client,
|
||||||
|
token,
|
||||||
|
title="Produkt Alpha",
|
||||||
|
goal="Kurz-Ziel",
|
||||||
|
vision="Ausführliches Zielbild für Release 1",
|
||||||
|
target_state_summary="MVP live auf Dev",
|
||||||
|
archetype_key="initiative.product",
|
||||||
|
)
|
||||||
|
assert created.status_code == 201
|
||||||
|
body = created.json()
|
||||||
|
assert body["archetype_key"] == "initiative.product"
|
||||||
|
assert body["vision"] == "Ausführliches Zielbild für Release 1"
|
||||||
|
assert body["target_state_summary"] == "MVP live auf Dev"
|
||||||
|
|
||||||
|
updated = client.patch(
|
||||||
|
f"/api/initiatives/{body['id']}",
|
||||||
|
json={
|
||||||
|
"archetype_key": "initiative.program",
|
||||||
|
"vision": "Programm-Vision aktualisiert",
|
||||||
|
},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert updated.status_code == 200
|
||||||
|
assert updated.json()["archetype_key"] == "initiative.program"
|
||||||
|
assert updated.json()["vision"] == "Programm-Vision aktualisiert"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_entity_archetypes(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="member")
|
||||||
|
token = _login(client, user)
|
||||||
|
|
||||||
|
res = client.get(
|
||||||
|
"/api/entity-archetypes?entity_type=initiative",
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
keys = {item["key"] for item in res.json()}
|
||||||
|
assert keys == {"initiative.generic", "initiative.program", "initiative.product"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_archetype_rejected(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="member")
|
||||||
|
token = _login(client, user)
|
||||||
|
|
||||||
|
res = _create_initiative(
|
||||||
|
client,
|
||||||
|
token,
|
||||||
|
title="Bad Archetype",
|
||||||
|
archetype_key="initiative.unknown",
|
||||||
|
)
|
||||||
|
assert res.status_code == 400
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
APP_VERSION = "0.16.9-ap1.12d-fix"
|
APP_VERSION = "0.17.0-ap1.10a"
|
||||||
DB_SCHEMA_VERSION = "015"
|
DB_SCHEMA_VERSION = "016"
|
||||||
APP_NAME = "jinkendo-kairo"
|
APP_NAME = "jinkendo-kairo"
|
||||||
|
|
|
||||||
6
frontend/src/api/entityArchetypes.js
Normal file
6
frontend/src/api/entityArchetypes.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { apiFetch } from './client.js'
|
||||||
|
|
||||||
|
export function listEntityArchetypes(entityType) {
|
||||||
|
const qs = entityType ? `?entity_type=${encodeURIComponent(entityType)}` : ''
|
||||||
|
return apiFetch(`/api/entity-archetypes${qs}`)
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
import { INITIATIVE_STATUSES, PRIORITIES } from '../constants/status.js'
|
import { INITIATIVE_STATUSES, PRIORITIES } from '../constants/status.js'
|
||||||
import { INITIATIVE_STATUS_LABELS, PRIORITY_LABELS } 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'
|
||||||
|
|
||||||
export function InitiativeForm({
|
export function InitiativeForm({
|
||||||
initial = {},
|
initial = {},
|
||||||
|
|
@ -8,12 +11,38 @@ export function InitiativeForm({
|
||||||
busy = false,
|
busy = false,
|
||||||
submitLabel = 'Speichern',
|
submitLabel = 'Speichern',
|
||||||
}) {
|
}) {
|
||||||
|
const [archetypes, setArchetypes] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
listEntityArchetypes('initiative')
|
||||||
|
.then((items) => {
|
||||||
|
if (!cancelled) setArchetypes(items)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setArchetypes(
|
||||||
|
Object.entries(INITIATIVE_ARCHETYPE_LABELS).map(([key, label]) => ({
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
async function handleSubmit(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const form = e.target
|
const form = e.target
|
||||||
await onSubmit({
|
await onSubmit({
|
||||||
title: form.title.value.trim(),
|
title: form.title.value.trim(),
|
||||||
|
archetype_key: form.archetype_key.value,
|
||||||
goal: form.goal.value,
|
goal: form.goal.value,
|
||||||
|
vision: form.vision.value,
|
||||||
|
target_state_summary: form.target_state_summary.value,
|
||||||
status: form.status.value,
|
status: form.status.value,
|
||||||
priority: form.priority.value,
|
priority: form.priority.value,
|
||||||
})
|
})
|
||||||
|
|
@ -26,31 +55,59 @@ export function InitiativeForm({
|
||||||
<input name="title" defaultValue={initial.title || ''} required maxLength={255} />
|
<input name="title" defaultValue={initial.title || ''} required maxLength={255} />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Ziel
|
Archetyp
|
||||||
<textarea name="goal" rows={3} defaultValue={initial.goal || ''} />
|
<select
|
||||||
</label>
|
name="archetype_key"
|
||||||
<label>
|
defaultValue={initial.archetype_key || 'initiative.generic'}
|
||||||
Status
|
disabled={!archetypes}
|
||||||
<select name="status" defaultValue={initial.status || 'active'}>
|
>
|
||||||
{INITIATIVE_STATUSES.map((s) => (
|
{(archetypes || []).map((item) => (
|
||||||
<option key={s} value={s}>
|
<option key={item.key} value={item.key}>
|
||||||
{INITIATIVE_STATUS_LABELS[s]}
|
{item.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Priorität
|
Kurzbeschreibung (Ziel)
|
||||||
<select name="priority" defaultValue={initial.priority || 'normal'}>
|
<textarea name="goal" rows={2} defaultValue={initial.goal || ''} />
|
||||||
{PRIORITIES.map((p) => (
|
|
||||||
<option key={p} value={p}>
|
|
||||||
{PRIORITY_LABELS[p]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
Vision
|
||||||
|
<textarea name="vision" rows={4} defaultValue={initial.vision || ''} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Zielzustand (Kurzfassung)
|
||||||
|
<textarea
|
||||||
|
name="target_state_summary"
|
||||||
|
rows={2}
|
||||||
|
defaultValue={initial.target_state_summary || ''}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="form-row form-row--2">
|
||||||
|
<label>
|
||||||
|
Status
|
||||||
|
<select name="status" defaultValue={initial.status || 'active'}>
|
||||||
|
{INITIATIVE_STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{INITIATIVE_STATUS_LABELS[s]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Priorität
|
||||||
|
<select name="priority" defaultValue={initial.priority || 'normal'}>
|
||||||
|
{PRIORITIES.map((p) => (
|
||||||
|
<option key={p} value={p}>
|
||||||
|
{PRIORITY_LABELS[p]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
<button type="submit" className="btn btn-primary" disabled={busy || !archetypes}>
|
||||||
{busy ? 'Speichern …' : submitLabel}
|
{busy ? 'Speichern …' : submitLabel}
|
||||||
</button>
|
</button>
|
||||||
{onCancel && (
|
{onCancel && (
|
||||||
|
|
|
||||||
10
frontend/src/constants/initiativeArchetypes.js
Normal file
10
frontend/src/constants/initiativeArchetypes.js
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
/** Fallback-Labels bis API geladen (AP1.10a). */
|
||||||
|
export const INITIATIVE_ARCHETYPE_LABELS = {
|
||||||
|
'initiative.generic': 'Allgemeines Vorhaben',
|
||||||
|
'initiative.program': 'Programm / Mega-Vorhaben',
|
||||||
|
'initiative.product': 'Produkt / Release',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initiativeArchetypeLabel(key) {
|
||||||
|
return INITIATIVE_ARCHETYPE_LABELS[key] || key || 'Allgemeines Vorhaben'
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import { Modal } from '../../components/Modal.jsx'
|
||||||
import { InitiativeForm } from '../../components/InitiativeForm.jsx'
|
import { InitiativeForm } from '../../components/InitiativeForm.jsx'
|
||||||
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
||||||
import { useProgramScope } from '../../context/ProgramScopeContext.jsx'
|
import { useProgramScope } from '../../context/ProgramScopeContext.jsx'
|
||||||
|
import { initiativeArchetypeLabel } from '../../constants/initiativeArchetypes.js'
|
||||||
|
|
||||||
function PlanProfileInner() {
|
function PlanProfileInner() {
|
||||||
const ops = useInitiativeOperations()
|
const ops = useInitiativeOperations()
|
||||||
|
|
@ -52,20 +53,36 @@ function PlanProfileInner() {
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
|
<div className="plan-profile__field">
|
||||||
|
<h3 className="plan-profile__label">Archetyp</h3>
|
||||||
|
<p>{initiativeArchetypeLabel(initiative.archetype_key)}</p>
|
||||||
|
</div>
|
||||||
{initiative.goal ? (
|
{initiative.goal ? (
|
||||||
<div className="plan-profile__field">
|
<div className="plan-profile__field">
|
||||||
<h3 className="plan-profile__label">Ziel</h3>
|
<h3 className="plan-profile__label">Kurzbeschreibung (Ziel)</h3>
|
||||||
<p>{initiative.goal}</p>
|
<p>{initiative.goal}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="muted">Noch kein Ziel hinterlegt.</p>
|
<p className="muted">Noch keine Kurzbeschreibung hinterlegt.</p>
|
||||||
|
)}
|
||||||
|
{initiative.vision && (
|
||||||
|
<div className="plan-profile__field">
|
||||||
|
<h3 className="plan-profile__label">Vision</h3>
|
||||||
|
<p>{initiative.vision}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{initiative.target_state_summary && (
|
||||||
|
<div className="plan-profile__field">
|
||||||
|
<h3 className="plan-profile__label">Zielzustand</h3>
|
||||||
|
<p>{initiative.target_state_summary}</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="muted plan-profile__note">
|
<p className="muted plan-profile__note">
|
||||||
Archetyp-Felder und dynamische Attribute folgen mit AP1.10 (Entity Field System).
|
Dynamische Zusatzfelder (EFS) folgen in AP1.10c.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Modal open={editing} title="Vorhaben bearbeiten" onClose={() => setEditing(false)}>
|
<Modal open={editing} title="Vorhaben bearbeiten" onClose={() => setEditing(false)} size="lg">
|
||||||
<InitiativeForm
|
<InitiativeForm
|
||||||
initial={initiative}
|
initial={initiative}
|
||||||
onSubmit={handleSave}
|
onSubmit={handleSave}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user