AP1.5c: Rekursive Projects fuer Mega-Programme ohne Stream-Typ.
All checks were successful
Deploy Development / deploy (push) Successful in 53s
Test Suite / pytest-backend (push) Successful in 1m42s
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 21s
Test Suite / playwright-smoke (push) Successful in 14s
All checks were successful
Deploy Development / deploy (push) Successful in 53s
Test Suite / pytest-backend (push) Successful in 1m42s
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 21s
Test Suite / playwright-smoke (push) Successful in 14s
parent_project_id und container_kind ermoeglichen verschachtelte Projektstruktur; Arbeitspakete bleiben an Blatt-Projekten gebunden. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
6eeddfdc76
commit
4ca0a2c460
12
backend/migrations/014_project_hierarchy.sql
Normal file
12
backend/migrations/014_project_hierarchy.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
-- AP1.5c: Rekursive Projects (parent_project_id, container_kind)
|
||||||
|
|
||||||
|
ALTER TABLE projects
|
||||||
|
ADD COLUMN parent_project_id UUID NULL REFERENCES projects(id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN container_kind VARCHAR(32) NULL
|
||||||
|
CHECK (
|
||||||
|
container_kind IS NULL
|
||||||
|
OR container_kind IN ('project', 'stream', 'phase', 'release')
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_projects_parent ON projects(tenant_id, parent_project_id)
|
||||||
|
WHERE parent_project_id IS NOT NULL;
|
||||||
|
|
@ -19,6 +19,8 @@ class ProjectCreateRequest(BaseModel):
|
||||||
title: str = Field(min_length=1, max_length=255)
|
title: str = Field(min_length=1, max_length=255)
|
||||||
description: str = ""
|
description: str = ""
|
||||||
status: Literal["active", "paused", "completed", "archived"] = "active"
|
status: Literal["active", "paused", "completed", "archived"] = "active"
|
||||||
|
parent_project_id: Optional[str] = None
|
||||||
|
container_kind: Optional[Literal["project", "stream", "phase", "release"]] = None
|
||||||
roadmap_item_id: Optional[str] = None
|
roadmap_item_id: Optional[str] = None
|
||||||
sort_order: int = 0
|
sort_order: int = 0
|
||||||
target_date: Optional[date] = None
|
target_date: Optional[date] = None
|
||||||
|
|
@ -28,6 +30,10 @@ class ProjectUpdateRequest(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)
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
status: Optional[Literal["active", "paused", "completed", "archived"]] = None
|
status: Optional[Literal["active", "paused", "completed", "archived"]] = None
|
||||||
|
parent_project_id: Optional[str] = None
|
||||||
|
clear_parent_project: bool = False
|
||||||
|
container_kind: Optional[Literal["project", "stream", "phase", "release"]] = None
|
||||||
|
clear_container_kind: bool = False
|
||||||
roadmap_item_id: Optional[str] = None
|
roadmap_item_id: Optional[str] = None
|
||||||
clear_roadmap_item: bool = False
|
clear_roadmap_item: bool = False
|
||||||
sort_order: Optional[int] = None
|
sort_order: Optional[int] = None
|
||||||
|
|
@ -62,6 +68,8 @@ def create_initiative_project(
|
||||||
title=body.title,
|
title=body.title,
|
||||||
description=body.description,
|
description=body.description,
|
||||||
status=body.status,
|
status=body.status,
|
||||||
|
parent_project_id=body.parent_project_id,
|
||||||
|
container_kind=body.container_kind,
|
||||||
roadmap_item_id=body.roadmap_item_id,
|
roadmap_item_id=body.roadmap_item_id,
|
||||||
sort_order=body.sort_order,
|
sort_order=body.sort_order,
|
||||||
target_date=body.target_date,
|
target_date=body.target_date,
|
||||||
|
|
@ -95,6 +103,10 @@ def update_project(
|
||||||
title=body.title,
|
title=body.title,
|
||||||
description=body.description,
|
description=body.description,
|
||||||
status=body.status,
|
status=body.status,
|
||||||
|
parent_project_id=body.parent_project_id,
|
||||||
|
clear_parent_project=body.clear_parent_project,
|
||||||
|
container_kind=body.container_kind,
|
||||||
|
clear_container_kind=body.clear_container_kind,
|
||||||
roadmap_item_id=body.roadmap_item_id,
|
roadmap_item_id=body.roadmap_item_id,
|
||||||
clear_roadmap_item=body.clear_roadmap_item,
|
clear_roadmap_item=body.clear_roadmap_item,
|
||||||
sort_order=body.sort_order,
|
sort_order=body.sort_order,
|
||||||
|
|
@ -113,7 +125,11 @@ def delete_project(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
ctx: TenantContext = Depends(require_capability("kairo.project.manage")),
|
ctx: TenantContext = Depends(require_capability("kairo.project.manage")),
|
||||||
):
|
):
|
||||||
if not project_service.delete_project(
|
try:
|
||||||
tenant_id=ctx.tenant_id, project_id=project_id, user_id=ctx.user_id
|
deleted = project_service.delete_project(
|
||||||
):
|
tenant_id=ctx.tenant_id, project_id=project_id, user_id=ctx.user_id
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
if not deleted:
|
||||||
raise HTTPException(status_code=404, detail="Projekt nicht gefunden")
|
raise HTTPException(status_code=404, detail="Projekt nicht gefunden")
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from psycopg2.extras import RealDictCursor
|
||||||
from db import get_connection
|
from db import get_connection
|
||||||
from services.audit import log_audit
|
from services.audit import log_audit
|
||||||
from services.initiatives import PRIORITIES, get_initiative
|
from services.initiatives import PRIORITIES, get_initiative
|
||||||
|
from services.projects import project_is_leaf
|
||||||
|
|
||||||
ActionStatus = Literal[
|
ActionStatus = Literal[
|
||||||
"open", "ready", "in_progress", "blocked", "review_required", "done", "discarded"
|
"open", "ready", "in_progress", "blocked", "review_required", "done", "discarded"
|
||||||
|
|
@ -116,6 +117,10 @@ def _validate_project_in_initiative(
|
||||||
)
|
)
|
||||||
if not cur.fetchone():
|
if not cur.fetchone():
|
||||||
raise ValueError("Projekt gehört nicht zum Vorhaben")
|
raise ValueError("Projekt gehört nicht zum Vorhaben")
|
||||||
|
if not project_is_leaf(tenant_id=tenant_id, project_id=project_id):
|
||||||
|
raise ValueError(
|
||||||
|
"Arbeitspakete nur an Blatt-Projekten (ohne Unterprojekte)"
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Project service — operative Struktur unter Initiative (AP1.5)."""
|
"""Project service — operative Struktur unter Initiative (AP1.5, AP1.5c)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -14,15 +14,21 @@ from services.initiatives import get_initiative
|
||||||
ProjectStatus = Literal["active", "paused", "completed", "archived"]
|
ProjectStatus = Literal["active", "paused", "completed", "archived"]
|
||||||
PROJECT_STATUSES = frozenset({"active", "paused", "completed", "archived"})
|
PROJECT_STATUSES = frozenset({"active", "paused", "completed", "archived"})
|
||||||
|
|
||||||
|
ContainerKind = Literal["project", "stream", "phase", "release"]
|
||||||
|
CONTAINER_KINDS = frozenset({"project", "stream", "phase", "release"})
|
||||||
|
|
||||||
|
MAX_PROJECT_DEPTH = 5
|
||||||
|
|
||||||
_PROJECT_COLUMNS = """
|
_PROJECT_COLUMNS = """
|
||||||
id, tenant_id, initiative_id, title, description, status,
|
id, tenant_id, initiative_id, parent_project_id, container_kind,
|
||||||
|
title, description, status,
|
||||||
roadmap_item_id, sort_order, target_date, created_at, updated_at
|
roadmap_item_id, sort_order, target_date, 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)
|
||||||
for key in ("id", "tenant_id", "initiative_id", "roadmap_item_id"):
|
for key in ("id", "tenant_id", "initiative_id", "parent_project_id", "roadmap_item_id"):
|
||||||
if result.get(key):
|
if result.get(key):
|
||||||
result[key] = str(result[key])
|
result[key] = str(result[key])
|
||||||
if result.get("target_date"):
|
if result.get("target_date"):
|
||||||
|
|
@ -42,6 +48,11 @@ def _validate_status(status: str) -> None:
|
||||||
raise ValueError(f"Ungültiger Project-Status: {status}")
|
raise ValueError(f"Ungültiger Project-Status: {status}")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_container_kind(container_kind: Optional[str]) -> None:
|
||||||
|
if container_kind is not None and container_kind not in CONTAINER_KINDS:
|
||||||
|
raise ValueError(f"Ungültiger container_kind: {container_kind}")
|
||||||
|
|
||||||
|
|
||||||
def _validate_roadmap_item(
|
def _validate_roadmap_item(
|
||||||
cur, *, tenant_id: str, initiative_id: str, roadmap_item_id: Optional[str]
|
cur, *, tenant_id: str, initiative_id: str, roadmap_item_id: Optional[str]
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -59,6 +70,151 @@ def _validate_roadmap_item(
|
||||||
raise ValueError("Gate/Plan-Element gehört nicht zu diesem Vorhaben")
|
raise ValueError("Gate/Plan-Element gehört nicht zu diesem Vorhaben")
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_project_row(
|
||||||
|
cur, *, tenant_id: str, project_id: str, initiative_id: Optional[str] = None
|
||||||
|
) -> Optional[dict[str, Any]]:
|
||||||
|
query = f"""
|
||||||
|
SELECT {_PROJECT_COLUMNS}
|
||||||
|
FROM projects
|
||||||
|
WHERE id = %s AND tenant_id = %s
|
||||||
|
"""
|
||||||
|
params: list[Any] = [project_id, tenant_id]
|
||||||
|
if initiative_id is not None:
|
||||||
|
query += " AND initiative_id = %s"
|
||||||
|
params.append(initiative_id)
|
||||||
|
cur.execute(query, params)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def _project_depth_from_map(
|
||||||
|
project_id: str, by_id: dict[str, dict[str, Any]], memo: dict[str, int]
|
||||||
|
) -> int:
|
||||||
|
if project_id in memo:
|
||||||
|
return memo[project_id]
|
||||||
|
project = by_id.get(project_id)
|
||||||
|
if not project or not project.get("parent_project_id"):
|
||||||
|
memo[project_id] = 1
|
||||||
|
return 1
|
||||||
|
parent_id = str(project["parent_project_id"])
|
||||||
|
if parent_id == project_id:
|
||||||
|
raise ValueError("Zyklus in Project-Hierarchie")
|
||||||
|
depth = _project_depth_from_map(parent_id, by_id, memo) + 1
|
||||||
|
memo[project_id] = depth
|
||||||
|
return depth
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_descendant_ids(
|
||||||
|
project_id: str, by_parent: dict[str, list[str]]
|
||||||
|
) -> set[str]:
|
||||||
|
result: set[str] = set()
|
||||||
|
stack = list(by_parent.get(project_id, []))
|
||||||
|
while stack:
|
||||||
|
child_id = stack.pop()
|
||||||
|
if child_id in result:
|
||||||
|
continue
|
||||||
|
result.add(child_id)
|
||||||
|
stack.extend(by_parent.get(child_id, []))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _subtree_height(project_id: str, by_parent: dict[str, list[str]]) -> int:
|
||||||
|
children = by_parent.get(project_id, [])
|
||||||
|
if not children:
|
||||||
|
return 1
|
||||||
|
return 1 + max(_subtree_height(child_id, by_parent) for child_id in children)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_initiative_project_maps(
|
||||||
|
cur, *, tenant_id: str, initiative_id: str
|
||||||
|
) -> tuple[dict[str, dict[str, Any]], dict[str, list[str]]]:
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT {_PROJECT_COLUMNS}
|
||||||
|
FROM projects
|
||||||
|
WHERE tenant_id = %s AND initiative_id = %s
|
||||||
|
""",
|
||||||
|
(tenant_id, initiative_id),
|
||||||
|
)
|
||||||
|
rows = [dict(r) for r in cur.fetchall()]
|
||||||
|
by_id = {str(r["id"]): r for r in rows}
|
||||||
|
by_parent: dict[str, list[str]] = {}
|
||||||
|
for row in rows:
|
||||||
|
parent_id = row.get("parent_project_id")
|
||||||
|
if parent_id:
|
||||||
|
by_parent.setdefault(str(parent_id), []).append(str(row["id"]))
|
||||||
|
return by_id, by_parent
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_parent_project(
|
||||||
|
cur,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
initiative_id: str,
|
||||||
|
project_id: Optional[str],
|
||||||
|
parent_project_id: Optional[str],
|
||||||
|
) -> None:
|
||||||
|
if not parent_project_id:
|
||||||
|
return
|
||||||
|
if project_id and parent_project_id == project_id:
|
||||||
|
raise ValueError("Projekt kann nicht sein eigener Parent sein")
|
||||||
|
|
||||||
|
parent = _fetch_project_row(
|
||||||
|
cur, tenant_id=tenant_id, project_id=parent_project_id, initiative_id=initiative_id
|
||||||
|
)
|
||||||
|
if not parent:
|
||||||
|
raise ValueError("Parent-Projekt gehört nicht zu diesem Vorhaben")
|
||||||
|
|
||||||
|
by_id, by_parent = _load_initiative_project_maps(
|
||||||
|
cur, tenant_id=tenant_id, initiative_id=initiative_id
|
||||||
|
)
|
||||||
|
if project_id:
|
||||||
|
descendants = _collect_descendant_ids(project_id, by_parent)
|
||||||
|
if parent_project_id in descendants:
|
||||||
|
raise ValueError("Zyklus: Parent darf kein Nachfahr sein")
|
||||||
|
|
||||||
|
memo: dict[str, int] = {}
|
||||||
|
parent_depth = _project_depth_from_map(parent_project_id, by_id, memo)
|
||||||
|
subtree_height = (
|
||||||
|
_subtree_height(project_id, by_parent) if project_id else 1
|
||||||
|
)
|
||||||
|
if parent_depth + subtree_height > MAX_PROJECT_DEPTH:
|
||||||
|
raise ValueError(
|
||||||
|
f"Maximale Project-Tiefe ({MAX_PROJECT_DEPTH}) würde überschritten"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_depths(projects: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
if not projects:
|
||||||
|
return projects
|
||||||
|
by_id = {p["id"]: p for p in projects}
|
||||||
|
memo: dict[str, int] = {}
|
||||||
|
for project in projects:
|
||||||
|
project["depth"] = _project_depth_from_map(project["id"], by_id, memo)
|
||||||
|
project["has_children"] = any(
|
||||||
|
other.get("parent_project_id") == project["id"] for other in projects
|
||||||
|
)
|
||||||
|
project["is_leaf"] = not project["has_children"]
|
||||||
|
return projects
|
||||||
|
|
||||||
|
|
||||||
|
def project_is_leaf(*, tenant_id: str, project_id: str) -> bool:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM projects
|
||||||
|
WHERE parent_project_id = %s AND tenant_id = %s
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(project_id, tenant_id),
|
||||||
|
)
|
||||||
|
return cur.fetchone() is None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def create_project(
|
def create_project(
|
||||||
*,
|
*,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
|
|
@ -66,6 +222,8 @@ def create_project(
|
||||||
title: str,
|
title: str,
|
||||||
description: str = "",
|
description: str = "",
|
||||||
status: ProjectStatus = "active",
|
status: ProjectStatus = "active",
|
||||||
|
parent_project_id: Optional[str] = None,
|
||||||
|
container_kind: Optional[ContainerKind] = None,
|
||||||
roadmap_item_id: Optional[str] = None,
|
roadmap_item_id: Optional[str] = None,
|
||||||
sort_order: int = 0,
|
sort_order: int = 0,
|
||||||
target_date: Optional[date] = None,
|
target_date: Optional[date] = None,
|
||||||
|
|
@ -75,6 +233,7 @@ def create_project(
|
||||||
if not title:
|
if not title:
|
||||||
raise ValueError("Titel ist erforderlich")
|
raise ValueError("Titel ist erforderlich")
|
||||||
_validate_status(status)
|
_validate_status(status)
|
||||||
|
_validate_container_kind(container_kind)
|
||||||
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
||||||
raise ValueError("Initiative nicht gefunden")
|
raise ValueError("Initiative nicht gefunden")
|
||||||
|
|
||||||
|
|
@ -87,18 +246,28 @@ def create_project(
|
||||||
initiative_id=initiative_id,
|
initiative_id=initiative_id,
|
||||||
roadmap_item_id=roadmap_item_id,
|
roadmap_item_id=roadmap_item_id,
|
||||||
)
|
)
|
||||||
|
_validate_parent_project(
|
||||||
|
cur,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
initiative_id=initiative_id,
|
||||||
|
project_id=None,
|
||||||
|
parent_project_id=parent_project_id,
|
||||||
|
)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
f"""
|
f"""
|
||||||
INSERT INTO projects (
|
INSERT INTO projects (
|
||||||
tenant_id, initiative_id, title, description, status,
|
tenant_id, initiative_id, parent_project_id, container_kind,
|
||||||
|
title, description, status,
|
||||||
roadmap_item_id, sort_order, target_date
|
roadmap_item_id, sort_order, target_date
|
||||||
)
|
)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
RETURNING {_PROJECT_COLUMNS}
|
RETURNING {_PROJECT_COLUMNS}
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
tenant_id,
|
tenant_id,
|
||||||
initiative_id,
|
initiative_id,
|
||||||
|
parent_project_id,
|
||||||
|
container_kind,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
status,
|
status,
|
||||||
|
|
@ -116,7 +285,12 @@ def create_project(
|
||||||
"project.created",
|
"project.created",
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
details={"project_id": row["id"], "initiative_id": initiative_id, "title": title},
|
details={
|
||||||
|
"project_id": row["id"],
|
||||||
|
"initiative_id": initiative_id,
|
||||||
|
"title": title,
|
||||||
|
"parent_project_id": parent_project_id,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
@ -139,7 +313,8 @@ def list_projects_for_initiative(
|
||||||
""",
|
""",
|
||||||
(tenant_id, initiative_id),
|
(tenant_id, initiative_id),
|
||||||
)
|
)
|
||||||
return [_serialize_row(dict(r)) for r in cur.fetchall()]
|
rows = [_serialize_row(dict(r)) for r in cur.fetchall()]
|
||||||
|
return _attach_depths(rows)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
@ -150,16 +325,25 @@ def get_project(*, tenant_id: str, project_id: str) -> Optional[dict[str, Any]]:
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT {_PROJECT_COLUMNS}
|
SELECT initiative_id
|
||||||
FROM projects WHERE id = %s AND tenant_id = %s
|
FROM projects WHERE id = %s AND tenant_id = %s
|
||||||
""",
|
""",
|
||||||
(project_id, tenant_id),
|
(project_id, tenant_id),
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
return _serialize_row(dict(row)) if row else None
|
if not row:
|
||||||
|
return None
|
||||||
|
initiative_id = str(row["initiative_id"])
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
for project in list_projects_for_initiative(
|
||||||
|
tenant_id=tenant_id, initiative_id=initiative_id
|
||||||
|
):
|
||||||
|
if project["id"] == project_id:
|
||||||
|
return project
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def update_project(
|
def update_project(
|
||||||
*,
|
*,
|
||||||
|
|
@ -169,6 +353,10 @@ def update_project(
|
||||||
title: Optional[str] = None,
|
title: Optional[str] = None,
|
||||||
description: Optional[str] = None,
|
description: Optional[str] = None,
|
||||||
status: Optional[ProjectStatus] = None,
|
status: Optional[ProjectStatus] = None,
|
||||||
|
parent_project_id: Optional[str] = None,
|
||||||
|
clear_parent_project: bool = False,
|
||||||
|
container_kind: Optional[ContainerKind] = None,
|
||||||
|
clear_container_kind: bool = False,
|
||||||
roadmap_item_id: Optional[str] = None,
|
roadmap_item_id: Optional[str] = None,
|
||||||
clear_roadmap_item: bool = False,
|
clear_roadmap_item: bool = False,
|
||||||
sort_order: Optional[int] = None,
|
sort_order: Optional[int] = None,
|
||||||
|
|
@ -195,6 +383,29 @@ def update_project(
|
||||||
_validate_status(status)
|
_validate_status(status)
|
||||||
updates.append("status = %s")
|
updates.append("status = %s")
|
||||||
params.append(status)
|
params.append(status)
|
||||||
|
if clear_parent_project:
|
||||||
|
updates.append("parent_project_id = NULL")
|
||||||
|
elif parent_project_id is not None:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
_validate_parent_project(
|
||||||
|
cur,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
initiative_id=existing["initiative_id"],
|
||||||
|
project_id=project_id,
|
||||||
|
parent_project_id=parent_project_id,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
updates.append("parent_project_id = %s")
|
||||||
|
params.append(parent_project_id)
|
||||||
|
if clear_container_kind:
|
||||||
|
updates.append("container_kind = NULL")
|
||||||
|
elif container_kind is not None:
|
||||||
|
_validate_container_kind(container_kind)
|
||||||
|
updates.append("container_kind = %s")
|
||||||
|
params.append(container_kind)
|
||||||
if clear_roadmap_item:
|
if clear_roadmap_item:
|
||||||
updates.append("roadmap_item_id = NULL")
|
updates.append("roadmap_item_id = NULL")
|
||||||
elif roadmap_item_id is not None:
|
elif roadmap_item_id is not None:
|
||||||
|
|
@ -251,7 +462,7 @@ def update_project(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
details={"project_id": project_id},
|
details={"project_id": project_id},
|
||||||
)
|
)
|
||||||
return result
|
return get_project(tenant_id=tenant_id, project_id=project_id)
|
||||||
|
|
||||||
|
|
||||||
def delete_project(
|
def delete_project(
|
||||||
|
|
@ -260,6 +471,18 @@ def delete_project(
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM projects
|
||||||
|
WHERE parent_project_id = %s AND tenant_id = %s
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(project_id, tenant_id),
|
||||||
|
)
|
||||||
|
if cur.fetchone():
|
||||||
|
raise ValueError(
|
||||||
|
"Projekt hat Unterprojekte — zuerst löschen oder verschieben"
|
||||||
|
)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"UPDATE actions SET project_id = NULL WHERE project_id = %s AND tenant_id = %s",
|
"UPDATE actions SET project_id = NULL WHERE project_id = %s AND tenant_id = %s",
|
||||||
(project_id, tenant_id),
|
(project_id, tenant_id),
|
||||||
|
|
|
||||||
111
backend/tests/test_ap15c_project_hierarchy.py
Normal file
111
backend/tests/test_ap15c_project_hierarchy.py
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
"""AP1.5c — Rekursive Projects (parent_project_id)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from tests.factories import provision_user_in_tenant
|
||||||
|
from tests.test_initiatives_actions import (
|
||||||
|
_auth,
|
||||||
|
_create_initiative,
|
||||||
|
_login,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nested_projects_and_depth(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="member")
|
||||||
|
token = _login(client, user)
|
||||||
|
initiative_id = _create_initiative(client, token).json()["id"]
|
||||||
|
|
||||||
|
root = client.post(
|
||||||
|
f"/api/initiatives/{initiative_id}/projects",
|
||||||
|
json={"title": "Programm", "container_kind": "project"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert root.status_code == 201
|
||||||
|
root_id = root.json()["id"]
|
||||||
|
|
||||||
|
stream = client.post(
|
||||||
|
f"/api/initiatives/{initiative_id}/projects",
|
||||||
|
json={
|
||||||
|
"title": "Backend Stream",
|
||||||
|
"parent_project_id": root_id,
|
||||||
|
"container_kind": "stream",
|
||||||
|
},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert stream.status_code == 201
|
||||||
|
stream_id = stream.json()["id"]
|
||||||
|
|
||||||
|
leaf = client.post(
|
||||||
|
f"/api/initiatives/{initiative_id}/projects",
|
||||||
|
json={"title": "API", "parent_project_id": stream_id},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert leaf.status_code == 201
|
||||||
|
leaf_id = leaf.json()["id"]
|
||||||
|
|
||||||
|
listed = client.get(
|
||||||
|
f"/api/initiatives/{initiative_id}/projects",
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert listed.status_code == 200
|
||||||
|
items = {p["id"]: p for p in listed.json()}
|
||||||
|
assert items[stream_id]["parent_project_id"] == root_id
|
||||||
|
assert items[leaf_id]["depth"] == 3
|
||||||
|
assert items[leaf_id]["is_leaf"] is True
|
||||||
|
assert items[root_id]["has_children"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_action_only_on_leaf_project(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="member")
|
||||||
|
token = _login(client, user)
|
||||||
|
initiative_id = _create_initiative(client, token).json()["id"]
|
||||||
|
|
||||||
|
parent = client.post(
|
||||||
|
f"/api/initiatives/{initiative_id}/projects",
|
||||||
|
json={"title": "Container"},
|
||||||
|
headers=_auth(token),
|
||||||
|
).json()
|
||||||
|
child = client.post(
|
||||||
|
f"/api/initiatives/{initiative_id}/projects",
|
||||||
|
json={"title": "Blatt", "parent_project_id": parent["id"]},
|
||||||
|
headers=_auth(token),
|
||||||
|
).json()
|
||||||
|
|
||||||
|
blocked = client.post(
|
||||||
|
f"/api/initiatives/{initiative_id}/actions",
|
||||||
|
json={"title": "Am Container", "project_id": parent["id"]},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert blocked.status_code == 400
|
||||||
|
assert "Blatt" in blocked.json()["detail"]
|
||||||
|
|
||||||
|
ok = client.post(
|
||||||
|
f"/api/initiatives/{initiative_id}/actions",
|
||||||
|
json={"title": "Am Blatt", "project_id": child["id"]},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert ok.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
def test_cannot_delete_project_with_children(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="member")
|
||||||
|
token = _login(client, user)
|
||||||
|
initiative_id = _create_initiative(client, token).json()["id"]
|
||||||
|
|
||||||
|
parent = client.post(
|
||||||
|
f"/api/initiatives/{initiative_id}/projects",
|
||||||
|
json={"title": "Parent"},
|
||||||
|
headers=_auth(token),
|
||||||
|
).json()
|
||||||
|
child = client.post(
|
||||||
|
f"/api/initiatives/{initiative_id}/projects",
|
||||||
|
json={"title": "Child", "parent_project_id": parent["id"]},
|
||||||
|
headers=_auth(token),
|
||||||
|
).json()
|
||||||
|
|
||||||
|
resp = client.delete(f"/api/projects/{parent['id']}", headers=_auth(token))
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
client.delete(f"/api/projects/{child['id']}", headers=_auth(token))
|
||||||
|
ok = client.delete(f"/api/projects/{parent['id']}", headers=_auth(token))
|
||||||
|
assert ok.status_code == 204
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
APP_VERSION = "0.14.0-ap1.5"
|
APP_VERSION = "0.14.1-ap1.5c"
|
||||||
DB_SCHEMA_VERSION = "013"
|
DB_SCHEMA_VERSION = "014"
|
||||||
APP_NAME = "jinkendo-kairo"
|
APP_NAME = "jinkendo-kairo"
|
||||||
|
|
|
||||||
|
|
@ -82,10 +82,12 @@ Minimaler Fix erlaubt: Tab-Beschreibung / Breadcrumb „Gates“ statt implizit
|
||||||
|
|
||||||
```text
|
```text
|
||||||
✓ AP1.4b Gate-Checkliste Backend + Minimal-Detail-Route
|
✓ AP1.4b Gate-Checkliste Backend + Minimal-Detail-Route
|
||||||
→ AP1.5 Project, Task, Ausführung als Ist-Kern (NÄCHSTES CODE-PAKET)
|
→ AP1.5 Project, Task, Ausführung als Ist-Kern ✓
|
||||||
|
→ AP1.5c Rekursive Projects (parent_project_id)
|
||||||
→ AP1.5b IA/Begriffe: Gates vs. Ausführung, optionale Gate-Sicht
|
→ AP1.5b IA/Begriffe: Gates vs. Ausführung, optionale Gate-Sicht
|
||||||
→ AP1.6 Plan/Ist-Verknüpfung, Journey (Action/Backlog → Gate)
|
→ AP1.6 Plan/Ist-Verknüpfung, Journey (Action/Backlog/Project → Gate)
|
||||||
⏸ AP1.4c Gate-UI (Modal, Checkliste-UX) — nach AP1.5
|
→ AP1.5d Rekursive Tasks (parent_task_id), Roll-up
|
||||||
|
⏸ AP1.4c Gate-UI (Modal, Checkliste-UX) — nach AP1.6
|
||||||
→ AP1.4d Graph Engine (Gate-Kanten only)
|
→ AP1.4d Graph Engine (Gate-Kanten only)
|
||||||
→ AP1.4e Methodenprofile (generic_operating ohne Gate-Zwang)
|
→ AP1.4e Methodenprofile (generic_operating ohne Gate-Zwang)
|
||||||
```
|
```
|
||||||
|
|
@ -101,9 +103,9 @@ Initiative (Vorhaben / Programm)
|
||||||
│ ▲
|
│ ▲
|
||||||
│ │ zahlt ein (Evidence, Zuordnung)
|
│ │ zahlt ein (Evidence, Zuordnung)
|
||||||
│ │
|
│ │
|
||||||
├── [optional] Project ← AP1.5
|
├── [optional] Project* ← AP1.5c parent_project_id
|
||||||
│ └── Action (Arbeitspaket) ← Ist — „Plan zwischen Gates“
|
│ └── Action (Arbeitspaket) ← Ist — „Plan zwischen Gates“
|
||||||
│ └── Task ← AP1.5 minimal
|
│ └── Task* ← AP1.5d parent_task_id
|
||||||
│
|
│
|
||||||
├── Backlog → Commit → Action
|
├── Backlog → Commit → Action
|
||||||
└── steering_context (Lifecycle, method_key) ← getrennt von Gate-Graph
|
└── steering_context (Lifecycle, method_key) ← getrennt von Gate-Graph
|
||||||
|
|
@ -155,4 +157,4 @@ Initiative (Vorhaben / Programm)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Referenz: `Kairo_Corrected_MVP_Roadmap_v0.2.md` (PO-Korrektur 2026-07-06), `Sprint1_AP1_5_Hierarchy_Assignment_v0.1.md`*
|
*Referenz: `Kairo_Corrected_MVP_Roadmap_v0.2.md` (PO-Korrektur 2026-07-06), `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md`, `Sprint1_AP1_5_Hierarchy_Assignment_v0.1.md`*
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,198 @@
|
||||||
|
# ADP — Rekursive Container & Ausführungs-Granularität v0.1
|
||||||
|
|
||||||
|
**Status:** PO-Freigabe (2026-07-06)
|
||||||
|
**Stand:** 2026-07-06
|
||||||
|
**Auslöser:** PO-Review — Mega-Programme vs. normale Vorhaben; Granularität für Mensch vs. Agent
|
||||||
|
**Bezug:** `ADP_PO_Product_Layering_and_IA_Reset_v0.1.md`, Canonical OM v0.2, Target State §10–11
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Zwei verwandte Fragen ohne feste Extra-Typen (`Stream`, `Subtask`, …):
|
||||||
|
|
||||||
|
1. **Organisation:** Sehr große Programme brauchen optional **Project in Project**; normale Vorhaben nicht.
|
||||||
|
2. **Ausführung:** Arbeitspakete und Aufgaben brauchen **methoden- und akteursabhängige Tiefe** — Menschen ~2–3 Untergliederungen, Agenten feiner.
|
||||||
|
|
||||||
|
Fest codierte Ebenen (`Stream`, `WorkPackage`, `Subtask` als eigene Typen) wären für kleine Vorhaben überdimensioniert und duplizieren semantisch **Project** bzw. **Task**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Betroffene Regeln
|
||||||
|
|
||||||
|
| Dokument | Regel |
|
||||||
|
|----------|--------|
|
||||||
|
| Canonical OM v0.2 §4 | Project optional; Action = Arbeitspaket; Task methodenabhängig |
|
||||||
|
| Vision v0.2 §4 | Action ≠ atomare Aufgabe; Task-Hierarchie / WBS |
|
||||||
|
| ADP Product Layering | Ist = Project → Action → Task; Gates orthogonal |
|
||||||
|
| Target State §10–11 | Steuerbare Objekte inkl. `project`; `SteeringContext` mit `scope_type` |
|
||||||
|
| Tenant Invariants | Kein paralleles Steering; Actor-first Assignments |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Optionen (Struktur)
|
||||||
|
|
||||||
|
| Option | Kurz | Pro | Contra |
|
||||||
|
|--------|------|-----|--------|
|
||||||
|
| A | Feste Typen `Stream`, `Subtask` | Klare UI-Labels | Schema-Ballast; Doppelung zu Project/Task |
|
||||||
|
| B | **Rekursion gleicher Typen** (`parent_*_id`) | Ein Modell für klein + groß | Baum-UI; Roll-up-Regeln nötig |
|
||||||
|
| C | Unbegrenzte Rekursion + Steuerung pro Ebene | Maximale Flexibilität | Steuerungs-Explosion; MVP-Verzögerung |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entscheidung (PO-Freigabe 2026-07-06)
|
||||||
|
|
||||||
|
### 1. Organisations-Ebene: rekursives **Project**
|
||||||
|
|
||||||
|
- **`parent_project_id`** nullable auf `projects` (Adjacency List).
|
||||||
|
- Gleiche Initiative; **Zyklen verboten**; **max. Tiefe 5** (hard block).
|
||||||
|
- Optional **`container_kind`** (Registry/Label): `project`, `stream`, `phase`, `release` — **nur Anzeige/Methoden-Hint**, keine Sonderlogik pro Kind.
|
||||||
|
- **Kein** eigener Typ `Stream`.
|
||||||
|
|
||||||
|
**Semantik nach Tiefe (UI, nicht Schema):**
|
||||||
|
|
||||||
|
| Tiefe | Typisches Label | Beispiel |
|
||||||
|
|-------|-----------------|----------|
|
||||||
|
| 0 | Projekt | „Kairo MVP“ |
|
||||||
|
| 1 | Stream / Phase | „Backend“, „Kumite“ |
|
||||||
|
| 2+ | Teilprojekt | „API-Schicht“ |
|
||||||
|
|
||||||
|
**Guardrails:**
|
||||||
|
|
||||||
|
- Actions bevorzugt an **Blatt-Projects** oder direkt an Initiative (Warnung bei Container mit Kindern).
|
||||||
|
- Gates (`roadmap_item_id`) an jeder Project-Tiefe oder Initiative — Plan/Ist in AP1.6.
|
||||||
|
|
||||||
|
**Implementierung:** **AP1.5c** — Migration `014` o.ä.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Ausführungs-Ebene: rekursives **Task** (Haupthebel Granularität)
|
||||||
|
|
||||||
|
- **`parent_task_id`** nullable auf `tasks` — gleiche Tabelle, Zeiger auf weitere Instanz.
|
||||||
|
- **Max. Tiefe Schema:** 10 (Agent/API).
|
||||||
|
- **Max. Tiefe UI (Menschen):** 3 Ebenen (Task → Subtask → Sub-Subtask) — **Policy**, kein Schema-Limit.
|
||||||
|
- Roll-up: Action `done` wenn Task-Baum erfüllt (Regeln in AP1.5d); Task `done` wenn alle Kinder `done` oder explizit ohne Kinder.
|
||||||
|
|
||||||
|
**Guardrails:**
|
||||||
|
|
||||||
|
- **Steuerung bleibt auf Action** (Assignment, Blocker, Evidence, Next Action für Menschen).
|
||||||
|
- Kein `steering_context` pro Task-Ebene.
|
||||||
|
- Agenten: Blatt-Tasks als Ausführungsschritte; tiefe Bäume in UI **aggregiert** anzeigen.
|
||||||
|
|
||||||
|
**Implementierung:** **AP1.5d** — **nach AP1.6** (Plan/Ist-Verknüpfung + Gate-Beitrag zuerst).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Action-Verschachtelung: **eingeschränkt**, nicht Default
|
||||||
|
|
||||||
|
- **`parent_action_id`** nur für **`wbs_driven`** (später), max. Tiefe **2**.
|
||||||
|
- Nur **Root-Action** aus Backlog-Commit; Kind-Actions = strukturelle Teilpakete desselben Commits.
|
||||||
|
- Default-Vorhaben: **eine Action + Task-Baum** — kein Action-Baum.
|
||||||
|
|
||||||
|
**Implementierung:** dokumentiert; Code **nicht** in AP1.5c/d — erst mit Methodenprofil / Structure Builder.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Steuerung: Vererbung, nicht Explosion
|
||||||
|
|
||||||
|
**Stufe 1 (MVP bis AP1.6+):**
|
||||||
|
|
||||||
|
```text
|
||||||
|
Initiative = einziger Steering-Root (steering_context, heute implementiert)
|
||||||
|
Project* = Struktur; erbt Initiative-Steuerung
|
||||||
|
Action = operative Steuerungs-Anker für Menschen (Assignment, Blocker, Evidence)
|
||||||
|
Task* = Ausführungsschritte; Roll-up, kein eigener Context
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stufe 2 (später, Mega-Programme):**
|
||||||
|
|
||||||
|
- Polymorpher `SteeringContext` (`scope_type`, `scope_id`) wie Target State.
|
||||||
|
- Nur Projects mit `steering_root = true` oder explizitem Override — **nicht** jede Ebene.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schichtenmodell (Ziel)
|
||||||
|
|
||||||
|
```text
|
||||||
|
Initiative ── steering_context (Pflicht)
|
||||||
|
│
|
||||||
|
├── [optional] Project* parent_project_id
|
||||||
|
│ └── Project* …
|
||||||
|
│ └── Action Commit-Grenze (Root aus Backlog)
|
||||||
|
│ └── Task* parent_task_id
|
||||||
|
│ └── Task* …
|
||||||
|
│
|
||||||
|
├── [optional] Gates (RoadmapItem) orthogonal
|
||||||
|
└── Backlog → Commit → Action (Root)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Zwei orthogonale Dimensionen:**
|
||||||
|
|
||||||
|
| Dimension | Rekursion | Mensch-Tiefe | Agent-Tiefe |
|
||||||
|
|-----------|-----------|--------------|-------------|
|
||||||
|
| Project (Organisation) | ja | 0–2 typisch | — |
|
||||||
|
| Action (Arbeitspaket) | selten (WBS) | 1 | 1–2 |
|
||||||
|
| Task (Ausführung) | ja | 2–3 | bis 10 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repriorisierung (verbindlich)
|
||||||
|
|
||||||
|
```text
|
||||||
|
✓ AP1.5 Project, Task flach, Ausführung (0.14.0-ap1.5)
|
||||||
|
→ AP1.5c parent_project_id, Baum-UI, container_kind optional
|
||||||
|
→ AP1.6 Plan/Ist-Verknüpfung, Journey
|
||||||
|
→ AP1.5d parent_task_id, Roll-up, UI-Tiefen-Policy
|
||||||
|
⏸ AP1.4c Gate-UI — weiter eingefroren
|
||||||
|
→ AP1.4d Gate-Graph
|
||||||
|
→ AP1.7 Operational Actor Interface (unverändert)
|
||||||
|
```
|
||||||
|
|
||||||
|
AP1.5b (IA-Begriffe) kann parallel zu AP1.5c.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Konsequenzen
|
||||||
|
|
||||||
|
| Bereich | Konsequenz |
|
||||||
|
|---------|------------|
|
||||||
|
| Schema | Zwei Migrationen: Project-Baum, Task-Baum — getrennte APs |
|
||||||
|
| UI | Baum/Outline statt nur flache Listen; Default collapsed ab Tiefe 2 |
|
||||||
|
| API | Cycle-Detection; depth validation |
|
||||||
|
| Tests | Klein (flach), Mega (Project-Tiefe 3), Agent (Task-Tiefe >3) |
|
||||||
|
| Steering | Keine Änderung in AP1.5c/d außer Roll-up-Fortschritt |
|
||||||
|
| Agent AP1.7 | Nutzt Task-Blätter; tiefe Zerlegung über AP1.5d |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risiko
|
||||||
|
|
||||||
|
| Risiko | Mitigation |
|
||||||
|
|--------|------------|
|
||||||
|
| UI-Overload durch Tiefe | UI soft cap 3 Tasks; Mega-Projects collapsed |
|
||||||
|
| Redundante Pfade (Project + Action + Task alle tief) | Guardrails + PO-Dokumentation |
|
||||||
|
| Commit-Modell bei nested Actions | Nested Actions erst WBS-Profil; Default flach |
|
||||||
|
| Steuerungs-Explosion | Stufe 1: nur Initiative-Context |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rückbaubarkeit
|
||||||
|
|
||||||
|
- `parent_*_id` nullable — flache Nutzung bleibt Default.
|
||||||
|
- `container_kind` optional — entfernbar ohne Schema-Bruch.
|
||||||
|
- Polymorpher SteeringContext ist **Add-on**, kein Breaking Change für AP1.5c/d.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Abnahme PO
|
||||||
|
|
||||||
|
- [x] Rekursives Project statt Stream-Typ
|
||||||
|
- [x] Rekursives Task für Granularität (Mensch 2–3, Agent tiefer)
|
||||||
|
- [x] Steering-Root = Initiative (Stufe 1)
|
||||||
|
- [x] AP1.5c → AP1.6 → AP1.5d Reihenfolge
|
||||||
|
- [x] Implementierung AP1.5c
|
||||||
|
- [ ] Implementierung AP1.5d
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Referenz: `Kairo_Corrected_MVP_Roadmap_v0.2.md` (Update 2026-07-06), `ADP_PO_Product_Layering_and_IA_Reset_v0.1.md`*
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
**Vision:** `Kairo_Vision_and_Product_Direction_v0.2.md`
|
**Vision:** `Kairo_Vision_and_Product_Direction_v0.2.md`
|
||||||
**Ist-Stand:** `Kairo_Implementation_Truth_Table_v0.1.md`
|
**Ist-Stand:** `Kairo_Implementation_Truth_Table_v0.1.md`
|
||||||
**PO-ADP:** `ADP_PO_Product_Layering_and_IA_Reset_v0.1.md`
|
**PO-ADP:** `ADP_PO_Product_Layering_and_IA_Reset_v0.1.md`
|
||||||
|
**Container-ADP:** `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -28,8 +29,8 @@
|
||||||
| Entscheidung | |
|
| Entscheidung | |
|
||||||
|--------------|--|
|
|--------------|--|
|
||||||
| AP1.4b Backend | ✓ behalten (Kriterien, Verify, Reopen) |
|
| AP1.4b Backend | ✓ behalten (Kriterien, Verify, Reopen) |
|
||||||
| Gate-UI-Politur (Modal, Graph-Workflow) | ⏸ **eingefroren** bis AP1.5 |
|
| Gate-UI-Politur (Modal, Graph-Workflow) | ⏸ **eingefroren** bis AP1.6 |
|
||||||
| Nächstes Code-Paket | **AP1.5** Hierarchie + Ausführung als Ist-Kern |
|
| Nächstes Code-Paket | **AP1.5c** rekursive Projects |
|
||||||
| Tab „Plan“ | fachlich **Gates / Zielzustände** — Umbenennung AP1.5b |
|
| Tab „Plan“ | fachlich **Gates / Zielzustände** — Umbenennung AP1.5b |
|
||||||
| Graph Engine AP1.4d | Gate-Kanten only — **kein** Lifecycle-Workflow |
|
| Graph Engine AP1.4d | Gate-Kanten only — **kein** Lifecycle-Workflow |
|
||||||
|
|
||||||
|
|
@ -37,6 +38,24 @@ Siehe `ADP_PO_Product_Layering_and_IA_Reset_v0.1.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 1.2 PO-Entscheidung 2026-07-06 — Rekursive Container & Task-Tiefe
|
||||||
|
|
||||||
|
**Auslöser:** Mega-Programme vs. normale Vorhaben; Granularität Mensch vs. Agent.
|
||||||
|
|
||||||
|
| Entscheidung | |
|
||||||
|
|--------------|--|
|
||||||
|
| Stream als eigener Typ | **Nein** — `Project` mit `parent_project_id` + optionalem `container_kind` |
|
||||||
|
| Project in Project | **Ja** — AP1.5c |
|
||||||
|
| Task → Subtask → … | **Ja** — `parent_task_id`; UI max. ~3 Ebenen; Schema tiefer für Agenten — AP1.5d |
|
||||||
|
| Nested Actions | **Optional** später (`wbs_driven` only); nicht Default |
|
||||||
|
| Steuerung pro Ebene | **Nein (Stufe 1)** — Initiative-Root; Action = Anker für Menschen |
|
||||||
|
|
||||||
|
Siehe `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md`.
|
||||||
|
|
||||||
|
**Reihenfolge:** AP1.5c → AP1.6 → AP1.5d
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2. Phasenübersicht
|
## 2. Phasenübersicht
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -47,11 +66,12 @@ Phase C Dokumentation Welle 1–2 ✓
|
||||||
Phase D IA-Skeleton AP1.2 + AP1.2c ✓
|
Phase D IA-Skeleton AP1.2 + AP1.2c ✓
|
||||||
Phase E Steering AP1.2, AP1.3 ◐
|
Phase E Steering AP1.2, AP1.3 ◐
|
||||||
Phase F Gate-Backend AP1.4 + AP1.4b ✓ (Backend); UI minimal
|
Phase F Gate-Backend AP1.4 + AP1.4b ✓ (Backend); UI minimal
|
||||||
Phase G **Ist-Hierarchie AP1.5** ← **NÄCHSTES CODE**
|
Phase G Ist-Hierarchie AP1.5 ✓
|
||||||
Phase H Plan/Ist-Verknüpfung AP1.6 geplant
|
Phase G2 Rekursive Container AP1.5c–5d ✓ 5c / → 5d nach AP1.6
|
||||||
Phase I Gate-Graph AP1.4d–4e nach AP1.5
|
Phase H Plan/Ist-Verknüpfung AP1.6 ← **NÄCHSTES CODE**
|
||||||
|
Phase I Gate-Graph AP1.4d–4e nach AP1.6
|
||||||
Phase J Portfolio & Kontext AP1.8 geplant
|
Phase J Portfolio & Kontext AP1.8 geplant
|
||||||
Phase K Agent Interface AP1.7 geplant
|
Phase K Agent Interface AP1.7 geplant (unverändert)
|
||||||
Phase L Validation AP0.10d (laufend PO) parallel
|
Phase L Validation AP0.10d (laufend PO) parallel
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -71,13 +91,15 @@ Phase L Validation AP0.10d (laufend PO) parallel
|
||||||
| AP1.2c | 0.12.0-ap1.2c | IA-Skeleton |
|
| AP1.2c | 0.12.0-ap1.2c | IA-Skeleton |
|
||||||
| AP1.4 | 0.13.0-ap1.4 | RoadmapItem / Gates Minimal |
|
| AP1.4 | 0.13.0-ap1.4 | RoadmapItem / Gates Minimal |
|
||||||
| AP1.4b | 0.13.1-ap1.4b | Gate-Checkliste Backend + Detail-Route |
|
| AP1.4b | 0.13.1-ap1.4b | Gate-Checkliste Backend + Detail-Route |
|
||||||
|
| AP1.5 | 0.14.0-ap1.5 | Project, Task flach, Ausführung-Tab |
|
||||||
| DOC PO | 2026-07-06 | ADP Product Layering & IA-Reset |
|
| DOC PO | 2026-07-06 | ADP Product Layering & IA-Reset |
|
||||||
|
| DOC Container | 2026-07-06 | ADP Recursive Containers & Execution Granularity |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Nächste Pakete (verbindliche Reihenfolge — PO 2026-07-06)
|
## 4. Nächste Pakete (verbindliche Reihenfolge — PO 2026-07-06)
|
||||||
|
|
||||||
### AP1.5 — Hierarchie & Ist-Ebene (**NÄCHSTES CODE-PAKET**)
|
### AP1.5 — Hierarchie & Ist-Ebene ✓
|
||||||
|
|
||||||
**Ziel:** Operativer Plan zwischen Gates — Project, Task, Ausführung-Tab; Gates optional nutzbar.
|
**Ziel:** Operativer Plan zwischen Gates — Project, Task, Ausführung-Tab; Gates optional nutzbar.
|
||||||
|
|
||||||
|
|
@ -94,6 +116,22 @@ Phase L Validation AP0.10d (laufend PO) parallel
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### AP1.5c — Rekursive Projects ✓
|
||||||
|
|
||||||
|
**Ziel:** Project-Baum für Mega-Programme; normale Vorhaben unverändert flach.
|
||||||
|
|
||||||
|
| Scope | |
|
||||||
|
|-------|--|
|
||||||
|
| `parent_project_id`, Zyklen-Check, max. Tiefe 5 |
|
||||||
|
| Optional `container_kind` (Label) |
|
||||||
|
| Baum-UI in Ausführung; Actions nur an Blatt-Projects |
|
||||||
|
| **Kein** eigener Steering-Context pro Project |
|
||||||
|
|
||||||
|
**ADP:** `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md`
|
||||||
|
**Version:** `0.14.1-ap1.5c`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### AP1.5b — IA-Begriffe Gates vs. Ausführung
|
### AP1.5b — IA-Begriffe Gates vs. Ausführung
|
||||||
|
|
||||||
| Scope | |
|
| Scope | |
|
||||||
|
|
@ -106,17 +144,35 @@ Phase L Validation AP0.10d (laufend PO) parallel
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### AP1.6 — Plan/Ist-Verknüpfung & Journey
|
### AP1.6 — Plan/Ist-Verknüpfung & Journey (**NÄCHSTES CODE-PAKET**)
|
||||||
|
|
||||||
Backlog/Action → RoadmapItem; Decision-Spur; Journey-Timeline.
|
Backlog/Action/Project/Task → RoadmapItem; Decision-Spur; Journey-Timeline.
|
||||||
|
|
||||||
**Nach AP1.5** — Gates bekommen Bedeutung durch Ist-Bezug.
|
**Nach AP1.5c** — Gates bekommen Bedeutung durch Ist-Bezug (auch auf Project-Ebenen).
|
||||||
|
|
||||||
**Version:** `0.15.0-ap1.6`
|
**Version:** `0.15.0-ap1.6`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### AP1.4c — Gate-UI-Politur ⏸ (eingefroren bis AP1.5)
|
### AP1.5d — Rekursive Tasks & Roll-up
|
||||||
|
|
||||||
|
**Ziel:** Task-Baum (`parent_task_id`); Mensch UI max. ~3 Ebenen; Agent tiefer.
|
||||||
|
|
||||||
|
| Scope | |
|
||||||
|
|-------|--|
|
||||||
|
| Migration `parent_task_id`, Cycle/Depth-Validation |
|
||||||
|
| Roll-up Status Action ↔ Task-Baum |
|
||||||
|
| Gate-Beitrag auf Blatt-Tasks (Fortsetzung AP1.6) |
|
||||||
|
| UI: Outline, collapsed default ab Tiefe 2 |
|
||||||
|
| **Kein** nested Action (Default) |
|
||||||
|
|
||||||
|
**ADP:** `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md`
|
||||||
|
**Nach AP1.6**
|
||||||
|
**Version:** `0.15.1-ap1.5d`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AP1.4c — Gate-UI-Politur ⏸ (eingefroren bis AP1.6)
|
||||||
|
|
||||||
Modal Gate anlegen/bearbeiten; Checkliste-UX; Fortschritt in Liste.
|
Modal Gate anlegen/bearbeiten; Checkliste-UX; Fortschritt in Liste.
|
||||||
|
|
||||||
|
|
@ -134,7 +190,7 @@ Modal Gate anlegen/bearbeiten; Checkliste-UX; Fortschritt in Liste.
|
||||||
| Deprecate `sequencing_mode` am Item |
|
| Deprecate `sequencing_mode` am Item |
|
||||||
| Read Models: blocked, ready, fulfillment_ratio |
|
| Read Models: blocked, ready, fulfillment_ratio |
|
||||||
|
|
||||||
**Version:** `0.14.1-ap1.4d` (nach AP1.5)
|
**Version:** `0.15.2-ap1.4d` (nach AP1.6)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -204,7 +260,7 @@ Criteria, Waive/Defer, Reopen, Detail-Route. Version `0.13.1-ap1.4b`
|
||||||
|
|
||||||
| Thema | Bis wann |
|
| Thema | Bis wann |
|
||||||
|-------|----------|
|
|-------|----------|
|
||||||
| Gate-UI-Politur (Modal, Graph-Workflow) | **Nach AP1.5** (PO 2026-07-06) |
|
| Gate-UI-Politur (Modal, Graph-Workflow) | **Nach AP1.6** (PO 2026-07-06) |
|
||||||
| Prompt/KI/MCP produktiv | Ist-Hierarchie + Plan/Ist |
|
| Prompt/KI/MCP produktiv | Ist-Hierarchie + Plan/Ist |
|
||||||
| Method Designer UI | AP1.3+ |
|
| Method Designer UI | AP1.3+ |
|
||||||
| Gantt / Kalender | nach Plan/Ist |
|
| Gantt / Kalender | nach Plan/Ist |
|
||||||
|
|
@ -230,12 +286,14 @@ MVP-nah wenn:
|
||||||
0.12.0-ap1.2c ✓ IA-Skeleton
|
0.12.0-ap1.2c ✓ IA-Skeleton
|
||||||
0.13.0-ap1.4 ✓ RoadmapItem / Gates Backend
|
0.13.0-ap1.4 ✓ RoadmapItem / Gates Backend
|
||||||
0.13.1-ap1.4b ✓ Gate-Checkliste Backend
|
0.13.1-ap1.4b ✓ Gate-Checkliste Backend
|
||||||
0.14.0-ap1.5 → Hierarchie (Project, Task) — NÄCHSTES
|
0.14.0-ap1.5 ✓ Hierarchie (Project, Task flach)
|
||||||
0.15.0-ap1.6 → Journey / Plan-Ist
|
0.14.1-ap1.5c ✓ Rekursive Projects
|
||||||
0.14.1-ap1.4d → Gate-Graph (nach AP1.5)
|
0.15.0-ap1.6 → Journey / Plan-Ist — NÄCHSTES
|
||||||
|
0.15.1-ap1.5d → Rekursive Tasks / Roll-up
|
||||||
|
0.15.2-ap1.4d → Gate-Graph
|
||||||
0.16.0-ap1.7 → Agent Interface
|
0.16.0-ap1.7 → Agent Interface
|
||||||
0.17.0-ap1.8 → Portfolio + Kontext
|
0.17.0-ap1.8 → Portfolio + Kontext
|
||||||
```
|
```
|
||||||
---
|
---
|
||||||
|
|
||||||
*Siehe auch: `ADP_PO_Product_Layering_and_IA_Reset_v0.1.md`, `Sprint1_AP1_5_Hierarchy_Assignment_v0.1.md`, `Kairo_MVP_Usability_Recovery_Plan_v0.2.md`*
|
*Siehe auch: `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md`, `ADP_PO_Product_Layering_and_IA_Reset_v0.1.md`, `Sprint1_AP1_5_Hierarchy_Assignment_v0.1.md`, `Kairo_MVP_Usability_Recovery_Plan_v0.2.md`*
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Kairo — Implementation Truth Table v0.1
|
# Kairo — Implementation Truth Table v0.1
|
||||||
|
|
||||||
**Status:** living document — bei jedem AP aktualisieren
|
**Status:** living document — bei jedem AP aktualisieren
|
||||||
**Stand:** 2026-07-06 (nach AP1.5)
|
**Stand:** 2026-07-06 (nach AP1.5c)
|
||||||
**Zweck:** Ehrliche Trennung von **implementiert**, **teilweise**, **nur API/Schema**, **nur Dokumentiert**
|
**Zweck:** Ehrliche Trennung von **implementiert**, **teilweise**, **nur API/Schema**, **nur Dokumentiert**
|
||||||
|
|
||||||
Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|
Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|
||||||
|
|
@ -30,7 +30,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|
||||||
| Capabilities | ✓ | Keine Objekt-Sichtbarkeit |
|
| Capabilities | ✓ | Keine Objekt-Sichtbarkeit |
|
||||||
| Auth / Session | ✓ | |
|
| Auth / Session | ✓ | |
|
||||||
| Audit (Auth/Admin) | ◐ | Nicht für alle OM-Events |
|
| Audit (Auth/Admin) | ◐ | Nicht für alle OM-Events |
|
||||||
| Migrationen nummeriert | ✓ | Schema 013 |
|
| Migrationen nummeriert | ✓ | Schema 014 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -39,7 +39,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|
||||||
| Element | Stand | Anmerkung |
|
| Element | Stand | Anmerkung |
|
||||||
|---------|-------|-----------|
|
|---------|-------|-----------|
|
||||||
| Initiative | ✓ | |
|
| Initiative | ✓ | |
|
||||||
| Project | ◐ | AP1.5 GUI + API |
|
| Project | ◐ | AP1.5 + AP1.5c: Baum (`parent_project_id`), Blatt-Regel für Actions |
|
||||||
| Action | ✓ | UI: Arbeitspaket (interim Maßnahme) |
|
| Action | ✓ | UI: Arbeitspaket (interim Maßnahme) |
|
||||||
| ActionAssignment | ✓ | |
|
| ActionAssignment | ✓ | |
|
||||||
| BacklogItem | ✓ | |
|
| BacklogItem | ✓ | |
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { ACTION_STATUSES, PRIORITIES } from '../constants/status.js'
|
import { ACTION_STATUSES, PRIORITIES } from '../constants/status.js'
|
||||||
import { ACTION_STATUS_LABELS, PRIORITY_LABELS } from '../constants/status.js'
|
import { ACTION_STATUS_LABELS, PRIORITY_LABELS } from '../constants/status.js'
|
||||||
import { ActorSelect } from './ActorSelect.jsx'
|
import { ActorSelect } from './ActorSelect.jsx'
|
||||||
|
import { flattenLeafProjectOptions, getLeafProjects } from '../utils/projectTree.js'
|
||||||
|
|
||||||
function isoToLocalInput(iso) {
|
function isoToLocalInput(iso) {
|
||||||
if (!iso) return ''
|
if (!iso) return ''
|
||||||
|
|
@ -38,6 +39,8 @@ export function ActionForm({
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultActors = initial.assigned_actor_ids || []
|
const defaultActors = initial.assigned_actor_ids || []
|
||||||
|
const leafProjects = getLeafProjects(projects)
|
||||||
|
const projectOptions = flattenLeafProjectOptions(projects)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="form workspace-form" onSubmit={handleSubmit}>
|
<form className="form workspace-form" onSubmit={handleSubmit}>
|
||||||
|
|
@ -77,14 +80,14 @@ export function ActionForm({
|
||||||
defaultValue={isoToLocalInput(initial.due_at)}
|
defaultValue={isoToLocalInput(initial.due_at)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{projects.length > 0 && (
|
{leafProjects.length > 0 && (
|
||||||
<label>
|
<label>
|
||||||
Projekt (optional)
|
Projekt (optional, Blatt-Ebene)
|
||||||
<select name="project_id" defaultValue={initial.project_id || ''}>
|
<select name="project_id" defaultValue={initial.project_id || ''}>
|
||||||
<option value="">— keins —</option>
|
<option value="">— keins —</option>
|
||||||
{projects.map((p) => (
|
{projectOptions.map(({ project, label }) => (
|
||||||
<option key={p.id} value={p.id}>
|
<option key={project.id} value={project.id}>
|
||||||
{p.title}
|
{label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,80 @@
|
||||||
import { useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { EmptyState } from './EmptyState.jsx'
|
import { EmptyState } from './EmptyState.jsx'
|
||||||
import { StatusBadge } from './StatusBadge.jsx'
|
import { StatusBadge } from './StatusBadge.jsx'
|
||||||
|
import {
|
||||||
|
buildProjectsByParent,
|
||||||
|
containerKindLabel,
|
||||||
|
flattenProjectOptions,
|
||||||
|
} from '../utils/projectTree.js'
|
||||||
|
|
||||||
|
function ProjectTreeNodes({
|
||||||
|
projects,
|
||||||
|
parentId,
|
||||||
|
depth,
|
||||||
|
selectedProjectId,
|
||||||
|
onSelectProject,
|
||||||
|
canManage,
|
||||||
|
onDelete,
|
||||||
|
busy,
|
||||||
|
}) {
|
||||||
|
const byParent = useMemo(() => buildProjectsByParent(projects), [projects])
|
||||||
|
const nodes = byParent.get(parentId || '') || []
|
||||||
|
|
||||||
|
if (nodes.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="project-tree-children">
|
||||||
|
{nodes.map((project) => (
|
||||||
|
<li key={project.id} className="project-tree-branch">
|
||||||
|
<div className="list-item card-list-item project-tree-item">
|
||||||
|
<div className="list-item-main">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="link-button"
|
||||||
|
onClick={() =>
|
||||||
|
onSelectProject(selectedProjectId === project.id ? '' : project.id)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<strong>{project.title}</strong>
|
||||||
|
</button>
|
||||||
|
{project.container_kind && (
|
||||||
|
<span className="project-kind-badge muted">
|
||||||
|
{containerKindLabel(project.container_kind)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{project.description && (
|
||||||
|
<p className="list-item-desc muted">{project.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="list-item-meta action-controls">
|
||||||
|
<StatusBadge kind="initiative" status={project.status} />
|
||||||
|
{canManage && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onDelete(project.id)}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ProjectTreeNodes
|
||||||
|
projects={projects}
|
||||||
|
parentId={project.id}
|
||||||
|
depth={depth + 1}
|
||||||
|
selectedProjectId={selectedProjectId}
|
||||||
|
onSelectProject={onSelectProject}
|
||||||
|
canManage={canManage}
|
||||||
|
onDelete={onDelete}
|
||||||
|
busy={busy}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function ProjectsSection({
|
export function ProjectsSection({
|
||||||
projects,
|
projects,
|
||||||
|
|
@ -13,37 +87,65 @@ export function ProjectsSection({
|
||||||
}) {
|
}) {
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
|
const [parentProjectId, setParentProjectId] = useState('')
|
||||||
|
const [containerKind, setContainerKind] = useState('')
|
||||||
const [showForm, setShowForm] = useState(false)
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
|
||||||
|
const parentOptions = useMemo(
|
||||||
|
() => flattenProjectOptions(projects),
|
||||||
|
[projects]
|
||||||
|
)
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
async function handleSubmit(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!title.trim()) return
|
if (!title.trim()) return
|
||||||
await onCreate({
|
await onCreate({
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
|
parent_project_id: parentProjectId || undefined,
|
||||||
|
container_kind: containerKind || undefined,
|
||||||
})
|
})
|
||||||
setTitle('')
|
setTitle('')
|
||||||
setDescription('')
|
setDescription('')
|
||||||
|
setParentProjectId('')
|
||||||
|
setContainerKind('')
|
||||||
setShowForm(false)
|
setShowForm(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openFormUnderSelected() {
|
||||||
|
setParentProjectId(selectedProjectId || '')
|
||||||
|
setShowForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="card projects-section">
|
<section className="card projects-section">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>Projekte</h2>
|
<h2>Projekte</h2>
|
||||||
<p className="section-lead muted">
|
<p className="section-lead muted">
|
||||||
Operative Struktur — Arbeitspakete gruppieren. Zielzustände (Gates) sind optional.
|
Operative Struktur — verschachtelbar für große Vorhaben. Arbeitspakete nur an
|
||||||
|
Blatt-Projekten. Gates optional.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<button
|
<div className="section-header-actions">
|
||||||
type="button"
|
{selectedProjectId && (
|
||||||
className="btn btn-primary btn-block-mobile"
|
<button
|
||||||
onClick={() => setShowForm((v) => !v)}
|
type="button"
|
||||||
>
|
className="btn btn-secondary btn-block-mobile"
|
||||||
{showForm ? 'Abbrechen' : 'Projekt'}
|
onClick={openFormUnderSelected}
|
||||||
</button>
|
>
|
||||||
|
Unterprojekt
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-block-mobile"
|
||||||
|
onClick={() => setShowForm((v) => !v)}
|
||||||
|
>
|
||||||
|
{showForm ? 'Abbrechen' : 'Projekt'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -67,6 +169,32 @@ export function ProjectsSection({
|
||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
{parentOptions.length > 0 && (
|
||||||
|
<label>
|
||||||
|
Unter (optional)
|
||||||
|
<select
|
||||||
|
value={parentProjectId}
|
||||||
|
onChange={(e) => setParentProjectId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">— oberste Ebene —</option>
|
||||||
|
{parentOptions.map(({ project, label }) => (
|
||||||
|
<option key={project.id} value={project.id}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label>
|
||||||
|
Art (optional)
|
||||||
|
<select value={containerKind} onChange={(e) => setContainerKind(e.target.value)}>
|
||||||
|
<option value="">— Standard —</option>
|
||||||
|
<option value="project">Projekt</option>
|
||||||
|
<option value="stream">Stream</option>
|
||||||
|
<option value="phase">Phase</option>
|
||||||
|
<option value="release">Release</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
Anlegen
|
Anlegen
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -77,7 +205,7 @@ export function ProjectsSection({
|
||||||
<EmptyState message="Noch keine Projekte — optional. Du kannst auch direkt Arbeitspakete anlegen." />
|
<EmptyState message="Noch keine Projekte — optional. Du kannst auch direkt Arbeitspakete anlegen." />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ul className="item-list project-filter-list">
|
<ul className="item-list project-filter-list project-tree-list">
|
||||||
<li className="list-item card-list-item">
|
<li className="list-item card-list-item">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -87,38 +215,17 @@ export function ProjectsSection({
|
||||||
Alle Arbeitspakete
|
Alle Arbeitspakete
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{projects.map((project) => (
|
|
||||||
<li key={project.id} className="list-item card-list-item">
|
|
||||||
<div className="list-item-main">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="link-button"
|
|
||||||
onClick={() =>
|
|
||||||
onSelectProject(selectedProjectId === project.id ? '' : project.id)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<strong>{project.title}</strong>
|
|
||||||
</button>
|
|
||||||
{project.description && (
|
|
||||||
<p className="list-item-desc muted">{project.description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="list-item-meta action-controls">
|
|
||||||
<StatusBadge kind="initiative" status={project.status} />
|
|
||||||
{canManage && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => onDelete(project.id)}
|
|
||||||
>
|
|
||||||
Löschen
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
</ul>
|
||||||
|
<ProjectTreeNodes
|
||||||
|
projects={projects}
|
||||||
|
parentId=""
|
||||||
|
depth={1}
|
||||||
|
selectedProjectId={selectedProjectId}
|
||||||
|
onSelectProject={onSelectProject}
|
||||||
|
canManage={canManage}
|
||||||
|
onDelete={onDelete}
|
||||||
|
busy={busy}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ import { listSteeringMethods, updateInitiativeSteeringMethod } from '../api/stee
|
||||||
import { useCapabilities } from '../hooks/useCapabilities.js'
|
import { useCapabilities } from '../hooks/useCapabilities.js'
|
||||||
import { useActors } from '../hooks/useActors.js'
|
import { useActors } from '../hooks/useActors.js'
|
||||||
import { useSession } from './SessionContext.jsx'
|
import { useSession } from './SessionContext.jsx'
|
||||||
|
import { collectProjectSubtreeIds } from '../utils/projectTree.js'
|
||||||
|
|
||||||
const InitiativeOperationsContext = createContext(null)
|
const InitiativeOperationsContext = createContext(null)
|
||||||
|
|
||||||
|
|
@ -154,17 +155,25 @@ export function InitiativeOperationsProvider({ children }) {
|
||||||
load()
|
load()
|
||||||
}, [load])
|
}, [load])
|
||||||
|
|
||||||
|
const selectedLeafProjectId = useMemo(() => {
|
||||||
|
if (!selectedProjectId) return ''
|
||||||
|
const match = projects.find((p) => p.id === selectedProjectId)
|
||||||
|
if (match?.is_leaf) return selectedProjectId
|
||||||
|
return ''
|
||||||
|
}, [selectedProjectId, projects])
|
||||||
|
|
||||||
const visibleActions = useMemo(
|
const visibleActions = useMemo(
|
||||||
() => {
|
() => {
|
||||||
let list = hideDone
|
let list = hideDone
|
||||||
? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded')
|
? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded')
|
||||||
: actions
|
: actions
|
||||||
if (selectedProjectId) {
|
if (selectedProjectId) {
|
||||||
list = list.filter((a) => a.project_id === selectedProjectId)
|
const subtreeIds = collectProjectSubtreeIds(projects, selectedProjectId)
|
||||||
|
list = list.filter((a) => a.project_id && subtreeIds.has(a.project_id))
|
||||||
}
|
}
|
||||||
return list
|
return list
|
||||||
},
|
},
|
||||||
[actions, hideDone, selectedProjectId]
|
[actions, hideDone, selectedProjectId, projects]
|
||||||
)
|
)
|
||||||
|
|
||||||
const actionContextById = useMemo(
|
const actionContextById = useMemo(
|
||||||
|
|
@ -200,7 +209,7 @@ export function InitiativeOperationsProvider({ children }) {
|
||||||
: []
|
: []
|
||||||
await createInitiativeAction(id, {
|
await createInitiativeAction(id, {
|
||||||
...payload,
|
...payload,
|
||||||
project_id: payload.project_id || selectedProjectId || undefined,
|
project_id: payload.project_id || selectedLeafProjectId || undefined,
|
||||||
assigned_actor_ids: assigned,
|
assigned_actor_ids: assigned,
|
||||||
})
|
})
|
||||||
setShowActionForm(false)
|
setShowActionForm(false)
|
||||||
|
|
|
||||||
|
|
@ -426,6 +426,38 @@
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-header-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-tree-list {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-tree-children {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 0 0.75rem;
|
||||||
|
border-left: 2px solid var(--jk-border, #e2e8f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-tree-branch {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-tree-item {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-kind-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-left: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
.section-header h2 {
|
.section-header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
|
|
||||||
80
frontend/src/utils/projectTree.js
Normal file
80
frontend/src/utils/projectTree.js
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
/** AP1.5c — Project-Baum Hilfsfunktionen */
|
||||||
|
|
||||||
|
const CONTAINER_KIND_LABELS = {
|
||||||
|
project: 'Projekt',
|
||||||
|
stream: 'Stream',
|
||||||
|
phase: 'Phase',
|
||||||
|
release: 'Release',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function containerKindLabel(kind) {
|
||||||
|
if (!kind) return null
|
||||||
|
return CONTAINER_KIND_LABELS[kind] || kind
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildProjectsByParent(projects) {
|
||||||
|
const byParent = new Map()
|
||||||
|
for (const project of projects) {
|
||||||
|
const key = project.parent_project_id || ''
|
||||||
|
if (!byParent.has(key)) byParent.set(key, [])
|
||||||
|
byParent.get(key).push(project)
|
||||||
|
}
|
||||||
|
for (const list of byParent.values()) {
|
||||||
|
list.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0) || a.title.localeCompare(b.title))
|
||||||
|
}
|
||||||
|
return byParent
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectProjectSubtreeIds(projects, rootId) {
|
||||||
|
const byParent = buildProjectsByParent(projects)
|
||||||
|
const ids = new Set([rootId])
|
||||||
|
const stack = [rootId]
|
||||||
|
while (stack.length) {
|
||||||
|
const current = stack.pop()
|
||||||
|
for (const child of byParent.get(current) || []) {
|
||||||
|
if (!ids.has(child.id)) {
|
||||||
|
ids.add(child.id)
|
||||||
|
stack.push(child.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLeafProjects(projects) {
|
||||||
|
const parentIds = new Set(
|
||||||
|
projects.map((p) => p.parent_project_id).filter(Boolean)
|
||||||
|
)
|
||||||
|
return projects.filter((p) => !parentIds.has(p.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectOptionLabel(project, depth = 1) {
|
||||||
|
const prefix = depth > 1 ? `${'— '.repeat(depth - 1)}` : ''
|
||||||
|
const kind = containerKindLabel(project.container_kind)
|
||||||
|
const kindSuffix = kind ? ` (${kind})` : ''
|
||||||
|
return `${prefix}${project.title}${kindSuffix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flattenLeafProjectOptions(projects, parentId = '', depth = 1) {
|
||||||
|
const byParent = buildProjectsByParent(projects)
|
||||||
|
const result = []
|
||||||
|
for (const project of byParent.get(parentId) || []) {
|
||||||
|
const children = byParent.get(project.id) || []
|
||||||
|
if (children.length === 0) {
|
||||||
|
result.push({ project, depth, label: projectOptionLabel(project, depth) })
|
||||||
|
} else {
|
||||||
|
result.push(...flattenLeafProjectOptions(projects, project.id, depth + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flattenProjectOptions(projects, parentId = '', depth = 1) {
|
||||||
|
const byParent = buildProjectsByParent(projects)
|
||||||
|
const result = []
|
||||||
|
for (const project of byParent.get(parentId) || []) {
|
||||||
|
result.push({ project, depth, label: projectOptionLabel(project, depth) })
|
||||||
|
result.push(...flattenProjectOptions(projects, project.id, depth + 1))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user