AP1.5: Project, Task und Ausführung als Ist-Kern
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 1m39s
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

Schema 013; Projekte/Arbeitspakete/Aufgaben pflegbar; Tab Zielzustände vs Ausführung.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-06 08:19:05 +02:00
parent 2e7651812a
commit 6eeddfdc76
27 changed files with 1459 additions and 50 deletions

View File

@ -68,11 +68,13 @@ from routers import ( # noqa: E402
initiatives,
me,
milestones,
projects,
roadmap,
prompts,
recurring,
reviews,
steering,
tasks,
workspace,
)
@ -83,6 +85,9 @@ app.include_router(prompts.router)
app.include_router(config.router)
app.include_router(initiatives.router)
app.include_router(actions.router)
app.include_router(projects.initiative_router)
app.include_router(projects.items_router)
app.include_router(tasks.router)
app.include_router(blockers.router)
app.include_router(backlog.router)
app.include_router(milestones.router)

View File

@ -0,0 +1,45 @@
-- AP1.5: Project, Task, Action.project_id — operative Ist-Hierarchie
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'paused', 'completed', 'archived')),
roadmap_item_id UUID NULL REFERENCES roadmap_items(id) ON DELETE SET NULL,
sort_order INT NOT NULL DEFAULT 0,
target_date DATE NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_projects_tenant_initiative ON projects(tenant_id, initiative_id);
CREATE INDEX idx_projects_tenant_status ON projects(tenant_id, status);
CREATE INDEX idx_projects_roadmap_item ON projects(tenant_id, roadmap_item_id)
WHERE roadmap_item_id IS NOT NULL;
ALTER TABLE actions
ADD COLUMN project_id UUID NULL REFERENCES projects(id) ON DELETE SET NULL;
CREATE INDEX idx_actions_project ON actions(tenant_id, project_id)
WHERE project_id IS NOT NULL;
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
action_id UUID NOT NULL REFERENCES actions(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'in_progress', 'done', 'discarded')),
roadmap_item_id UUID NULL REFERENCES roadmap_items(id) ON DELETE SET NULL,
sort_order INT NOT NULL DEFAULT 0,
due_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_tasks_tenant_action ON tasks(tenant_id, action_id);
CREATE INDEX idx_tasks_tenant_status ON tasks(tenant_id, status);

View File

@ -171,3 +171,21 @@ register_capability(
default_grants=(("portal", "admin"),),
)
)
register_capability(
CapabilityRegistration(
key="kairo.project.read",
module="project",
description="Projekte im aktiven Tenant lesen",
default_grants=_MEMBER_READ,
)
)
register_capability(
CapabilityRegistration(
key="kairo.project.manage",
module="project",
description="Projekte anlegen und bearbeiten",
default_grants=_MEMBER_MANAGE,
)
)

View File

@ -10,6 +10,7 @@ from data_layer import actions as dl_actions
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from services import actions as action_service
from services import tasks as task_service
from tenant_context import TenantContext
router = APIRouter(prefix="/api/actions", tags=["actions"])
@ -24,6 +25,17 @@ class ActionUpdateRequest(BaseModel):
priority: Optional[Literal["low", "normal", "high"]] = None
due_at: Optional[str] = None
clear_due_at: bool = False
project_id: Optional[str] = None
clear_project: bool = False
class TaskCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
description: str = ""
status: Literal["open", "in_progress", "done", "discarded"] = "open"
roadmap_item_id: Optional[str] = None
sort_order: int = 0
due_at: Optional[str] = None
class ActionAssignmentsRequest(BaseModel):
@ -40,6 +52,47 @@ def list_my_open_actions(
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/{action_id}/tasks")
def list_action_tasks(
action_id: str,
ctx: TenantContext = Depends(require_capability("kairo.action.read")),
):
try:
return task_service.list_tasks_for_action(
tenant_id=ctx.tenant_id, action_id=action_id
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/{action_id}/tasks", status_code=201)
def create_action_task(
action_id: str,
body: TaskCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
):
due_at = None
if body.due_at:
try:
due_at = datetime.fromisoformat(body.due_at.replace("Z", "+00:00"))
except ValueError as exc:
raise HTTPException(status_code=400, detail="Ungültiges due_at") from exc
try:
return task_service.create_task(
tenant_id=ctx.tenant_id,
action_id=action_id,
user_id=ctx.user_id,
title=body.title,
description=body.description,
status=body.status,
roadmap_item_id=body.roadmap_item_id,
sort_order=body.sort_order,
due_at=due_at,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/{action_id}")
def get_action(
action_id: str,
@ -74,6 +127,8 @@ def update_action(
priority=body.priority,
due_at=due_at,
clear_due_at=body.clear_due_at,
project_id=body.project_id,
clear_project=body.clear_project,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc

View File

@ -48,6 +48,7 @@ class ActionCreateRequest(BaseModel):
] = "open"
priority: Literal["low", "normal", "high"] = "normal"
due_at: Optional[str] = None
project_id: Optional[str] = None
assigned_actor_ids: list[str] = Field(default_factory=list)
@ -249,6 +250,7 @@ def create_initiative_action(
status=body.status,
priority=body.priority,
due_at=due_at,
project_id=body.project_id,
assigned_actor_ids=body.assigned_actor_ids,
user_id=ctx.user_id,
)

119
backend/routers/projects.py Normal file
View File

@ -0,0 +1,119 @@
"""Project API — AP1.5."""
from __future__ import annotations
from datetime import date
from typing import Literal, Optional
from capabilities import require_capability
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from services import projects as project_service
from tenant_context import TenantContext
initiative_router = APIRouter(prefix="/api/initiatives", tags=["projects"])
items_router = APIRouter(prefix="/api/projects", tags=["projects"])
class ProjectCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
description: str = ""
status: Literal["active", "paused", "completed", "archived"] = "active"
roadmap_item_id: Optional[str] = None
sort_order: int = 0
target_date: Optional[date] = None
class ProjectUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
description: Optional[str] = None
status: Optional[Literal["active", "paused", "completed", "archived"]] = None
roadmap_item_id: Optional[str] = None
clear_roadmap_item: bool = False
sort_order: Optional[int] = None
target_date: Optional[date] = None
clear_target_date: bool = False
@initiative_router.get("/{initiative_id}/projects")
def list_initiative_projects(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.project.read")),
):
try:
return project_service.list_projects_for_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@initiative_router.post("/{initiative_id}/projects", status_code=201)
def create_initiative_project(
initiative_id: str,
body: ProjectCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.project.manage")),
):
try:
return project_service.create_project(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
user_id=ctx.user_id,
title=body.title,
description=body.description,
status=body.status,
roadmap_item_id=body.roadmap_item_id,
sort_order=body.sort_order,
target_date=body.target_date,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@items_router.get("/{project_id}")
def get_project(
project_id: str,
ctx: TenantContext = Depends(require_capability("kairo.project.read")),
):
item = project_service.get_project(tenant_id=ctx.tenant_id, project_id=project_id)
if not item:
raise HTTPException(status_code=404, detail="Projekt nicht gefunden")
return item
@items_router.patch("/{project_id}")
def update_project(
project_id: str,
body: ProjectUpdateRequest,
ctx: TenantContext = Depends(require_capability("kairo.project.manage")),
):
try:
item = project_service.update_project(
tenant_id=ctx.tenant_id,
project_id=project_id,
user_id=ctx.user_id,
title=body.title,
description=body.description,
status=body.status,
roadmap_item_id=body.roadmap_item_id,
clear_roadmap_item=body.clear_roadmap_item,
sort_order=body.sort_order,
target_date=body.target_date,
clear_target_date=body.clear_target_date,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="Projekt nicht gefunden")
return item
@items_router.delete("/{project_id}", status_code=204)
def delete_project(
project_id: str,
ctx: TenantContext = Depends(require_capability("kairo.project.manage")),
):
if not project_service.delete_project(
tenant_id=ctx.tenant_id, project_id=project_id, user_id=ctx.user_id
):
raise HTTPException(status_code=404, detail="Projekt nicht gefunden")

69
backend/routers/tasks.py Normal file
View File

@ -0,0 +1,69 @@
"""Task item API — AP1.5."""
from __future__ import annotations
from datetime import datetime
from typing import Literal, Optional
from capabilities import require_capability
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from services import tasks as task_service
from tenant_context import TenantContext
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
class TaskUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
description: Optional[str] = None
status: Optional[Literal["open", "in_progress", "done", "discarded"]] = None
roadmap_item_id: Optional[str] = None
clear_roadmap_item: bool = False
sort_order: Optional[int] = None
due_at: Optional[str] = None
clear_due_at: bool = False
@router.patch("/{task_id}")
def update_task(
task_id: str,
body: TaskUpdateRequest,
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
):
due_at = None
if body.due_at is not None:
try:
due_at = datetime.fromisoformat(body.due_at.replace("Z", "+00:00"))
except ValueError as exc:
raise HTTPException(status_code=400, detail="Ungültiges due_at") from exc
try:
item = task_service.update_task(
tenant_id=ctx.tenant_id,
task_id=task_id,
user_id=ctx.user_id,
title=body.title,
description=body.description,
status=body.status,
roadmap_item_id=body.roadmap_item_id,
clear_roadmap_item=body.clear_roadmap_item,
sort_order=body.sort_order,
due_at=due_at,
clear_due_at=body.clear_due_at,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="Task nicht gefunden")
return item
@router.delete("/{task_id}", status_code=204)
def delete_task(
task_id: str,
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
):
if not task_service.delete_task(
tenant_id=ctx.tenant_id, task_id=task_id, user_id=ctx.user_id
):
raise HTTPException(status_code=404, detail="Task nicht gefunden")

View File

@ -22,14 +22,14 @@ OPEN_ACTION_STATUSES = frozenset(
)
_ACTION_COLUMNS = """
id, tenant_id, initiative_id, title, description,
id, tenant_id, initiative_id, project_id, title, description,
status, priority, due_at, created_at, updated_at
"""
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row)
for key in ("id", "tenant_id", "initiative_id", "owner_actor_id"):
for key in ("id", "tenant_id", "initiative_id", "project_id", "owner_actor_id"):
if result.get(key):
result[key] = str(result[key])
if result.get("created_at"):
@ -99,6 +99,27 @@ def _actor_in_tenant(*, tenant_id: str, actor_id: str) -> bool:
conn.close()
def _validate_project_in_initiative(
*, tenant_id: str, initiative_id: str, project_id: Optional[str]
) -> None:
if not project_id:
return
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT 1 FROM projects
WHERE id = %s AND tenant_id = %s AND initiative_id = %s
""",
(project_id, tenant_id, initiative_id),
)
if not cur.fetchone():
raise ValueError("Projekt gehört nicht zum Vorhaben")
finally:
conn.close()
def create_action(
*,
tenant_id: str,
@ -108,6 +129,7 @@ def create_action(
status: ActionStatus = "open",
priority: str = "normal",
due_at: Optional[Any] = None,
project_id: Optional[str] = None,
assigned_actor_ids: Optional[list[str]] = None,
user_id: Optional[str] = None,
) -> dict[str, Any]:
@ -118,6 +140,9 @@ def create_action(
_validate_priority(priority)
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
_validate_project_in_initiative(
tenant_id=tenant_id, initiative_id=initiative_id, project_id=project_id
)
assigned_actor_ids = assigned_actor_ids or []
for actor_id in assigned_actor_ids:
@ -130,12 +155,13 @@ def create_action(
cur.execute(
f"""
INSERT INTO actions (
tenant_id, initiative_id, title, description, status, priority, due_at
tenant_id, initiative_id, project_id, title, description,
status, priority, due_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING {_ACTION_COLUMNS}
""",
(tenant_id, initiative_id, title, description, status, priority, due_at),
(tenant_id, initiative_id, project_id, title, description, status, priority, due_at),
)
row = _serialize_row(dict(cur.fetchone()))
for actor_id in assigned_actor_ids:
@ -221,6 +247,8 @@ def update_action(
priority: Optional[str] = None,
due_at: Optional[Any] = None,
clear_due_at: bool = False,
project_id: Optional[str] = None,
clear_project: bool = False,
) -> Optional[dict[str, Any]]:
existing = get_action(tenant_id=tenant_id, action_id=action_id)
if not existing:
@ -252,6 +280,16 @@ def update_action(
elif due_at is not None:
updates.append("due_at = %s")
params.append(due_at)
if clear_project:
updates.append("project_id = NULL")
elif project_id is not None:
_validate_project_in_initiative(
tenant_id=tenant_id,
initiative_id=existing["initiative_id"],
project_id=project_id,
)
updates.append("project_id = %s")
params.append(project_id)
if not updates:
return existing

View File

@ -0,0 +1,283 @@
"""Project service — operative Struktur unter Initiative (AP1.5)."""
from __future__ import annotations
from datetime import date
from typing import Any, Literal, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from services.audit import log_audit
from services.initiatives import get_initiative
ProjectStatus = Literal["active", "paused", "completed", "archived"]
PROJECT_STATUSES = frozenset({"active", "paused", "completed", "archived"})
_PROJECT_COLUMNS = """
id, tenant_id, initiative_id, title, description, status,
roadmap_item_id, sort_order, target_date, created_at, updated_at
"""
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row)
for key in ("id", "tenant_id", "initiative_id", "roadmap_item_id"):
if result.get(key):
result[key] = str(result[key])
if result.get("target_date"):
result["target_date"] = (
result["target_date"].isoformat()
if hasattr(result["target_date"], "isoformat")
else str(result["target_date"])
)
for ts in ("created_at", "updated_at"):
if result.get(ts):
result[ts] = result[ts].isoformat()
return result
def _validate_status(status: str) -> None:
if status not in PROJECT_STATUSES:
raise ValueError(f"Ungültiger Project-Status: {status}")
def _validate_roadmap_item(
cur, *, tenant_id: str, initiative_id: str, roadmap_item_id: Optional[str]
) -> None:
if not roadmap_item_id:
return
cur.execute(
"""
SELECT 1 FROM roadmap_items ri
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
WHERE ri.id = %s AND ri.tenant_id = %s AND r.initiative_id = %s
""",
(roadmap_item_id, tenant_id, initiative_id),
)
if not cur.fetchone():
raise ValueError("Gate/Plan-Element gehört nicht zu diesem Vorhaben")
def create_project(
*,
tenant_id: str,
initiative_id: str,
title: str,
description: str = "",
status: ProjectStatus = "active",
roadmap_item_id: Optional[str] = None,
sort_order: int = 0,
target_date: Optional[date] = None,
user_id: Optional[str] = None,
) -> dict[str, Any]:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
_validate_status(status)
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
_validate_roadmap_item(
cur,
tenant_id=tenant_id,
initiative_id=initiative_id,
roadmap_item_id=roadmap_item_id,
)
cur.execute(
f"""
INSERT INTO projects (
tenant_id, initiative_id, title, description, status,
roadmap_item_id, sort_order, target_date
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING {_PROJECT_COLUMNS}
""",
(
tenant_id,
initiative_id,
title,
description,
status,
roadmap_item_id,
sort_order,
target_date,
),
)
row = _serialize_row(dict(cur.fetchone()))
conn.commit()
finally:
conn.close()
log_audit(
"project.created",
user_id=user_id,
tenant_id=tenant_id,
details={"project_id": row["id"], "initiative_id": initiative_id, "title": title},
)
return row
def list_projects_for_initiative(
*, tenant_id: str, initiative_id: str
) -> list[dict[str, Any]]:
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
SELECT {_PROJECT_COLUMNS}
FROM projects
WHERE tenant_id = %s AND initiative_id = %s
ORDER BY sort_order ASC, updated_at DESC, title
""",
(tenant_id, initiative_id),
)
return [_serialize_row(dict(r)) for r in cur.fetchall()]
finally:
conn.close()
def get_project(*, tenant_id: str, project_id: str) -> Optional[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
SELECT {_PROJECT_COLUMNS}
FROM projects WHERE id = %s AND tenant_id = %s
""",
(project_id, tenant_id),
)
row = cur.fetchone()
return _serialize_row(dict(row)) if row else None
finally:
conn.close()
def update_project(
*,
tenant_id: str,
project_id: str,
user_id: Optional[str] = None,
title: Optional[str] = None,
description: Optional[str] = None,
status: Optional[ProjectStatus] = None,
roadmap_item_id: Optional[str] = None,
clear_roadmap_item: bool = False,
sort_order: Optional[int] = None,
target_date: Optional[date] = None,
clear_target_date: bool = False,
) -> Optional[dict[str, Any]]:
existing = get_project(tenant_id=tenant_id, project_id=project_id)
if not existing:
return None
updates: list[str] = []
params: list[Any] = []
if title is not None:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
updates.append("title = %s")
params.append(title)
if description is not None:
updates.append("description = %s")
params.append(description)
if status is not None:
_validate_status(status)
updates.append("status = %s")
params.append(status)
if clear_roadmap_item:
updates.append("roadmap_item_id = NULL")
elif roadmap_item_id is not None:
conn = get_connection()
try:
with conn.cursor() as cur:
_validate_roadmap_item(
cur,
tenant_id=tenant_id,
initiative_id=existing["initiative_id"],
roadmap_item_id=roadmap_item_id,
)
finally:
conn.close()
updates.append("roadmap_item_id = %s")
params.append(roadmap_item_id)
if sort_order is not None:
updates.append("sort_order = %s")
params.append(sort_order)
if clear_target_date:
updates.append("target_date = NULL")
elif target_date is not None:
updates.append("target_date = %s")
params.append(target_date)
if not updates:
return existing
updates.append("updated_at = NOW()")
params.extend([project_id, tenant_id])
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
UPDATE projects SET {", ".join(updates)}
WHERE id = %s AND tenant_id = %s
RETURNING {_PROJECT_COLUMNS}
""",
params,
)
row = cur.fetchone()
if not row:
return None
result = _serialize_row(dict(row))
conn.commit()
finally:
conn.close()
log_audit(
"project.updated",
user_id=user_id,
tenant_id=tenant_id,
details={"project_id": project_id},
)
return result
def delete_project(
*, tenant_id: str, project_id: str, user_id: Optional[str] = None
) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"UPDATE actions SET project_id = NULL WHERE project_id = %s AND tenant_id = %s",
(project_id, tenant_id),
)
cur.execute(
"DELETE FROM projects WHERE id = %s AND tenant_id = %s RETURNING id",
(project_id, tenant_id),
)
deleted = cur.fetchone() is not None
conn.commit()
finally:
conn.close()
if deleted:
log_audit(
"project.deleted",
user_id=user_id,
tenant_id=tenant_id,
details={"project_id": project_id},
)
return deleted

273
backend/services/tasks.py Normal file
View File

@ -0,0 +1,273 @@
"""Task service — kleinste Einheit unter Action (AP1.5)."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from services.actions import get_action
from services.audit import log_audit
TaskStatus = Literal["open", "in_progress", "done", "discarded"]
TASK_STATUSES = frozenset({"open", "in_progress", "done", "discarded"})
_TASK_COLUMNS = """
id, tenant_id, action_id, title, description, status,
roadmap_item_id, sort_order, due_at, created_at, updated_at
"""
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row)
for key in ("id", "tenant_id", "action_id", "roadmap_item_id"):
if result.get(key):
result[key] = str(result[key])
if result.get("due_at"):
result["due_at"] = result["due_at"].isoformat()
for ts in ("created_at", "updated_at"):
if result.get(ts):
result[ts] = result[ts].isoformat()
return result
def _validate_status(status: str) -> None:
if status not in TASK_STATUSES:
raise ValueError(f"Ungültiger Task-Status: {status}")
def _get_action_or_raise(*, tenant_id: str, action_id: str) -> dict[str, Any]:
action = get_action(tenant_id=tenant_id, action_id=action_id)
if not action:
raise ValueError("Arbeitspaket nicht gefunden")
return action
def _validate_roadmap_item_for_action(
cur, *, tenant_id: str, initiative_id: str, roadmap_item_id: Optional[str]
) -> None:
if not roadmap_item_id:
return
cur.execute(
"""
SELECT 1 FROM roadmap_items ri
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
WHERE ri.id = %s AND ri.tenant_id = %s AND r.initiative_id = %s
""",
(roadmap_item_id, tenant_id, initiative_id),
)
if not cur.fetchone():
raise ValueError("Gate gehört nicht zum Vorhaben des Arbeitspakets")
def create_task(
*,
tenant_id: str,
action_id: str,
title: str,
description: str = "",
status: TaskStatus = "open",
roadmap_item_id: Optional[str] = None,
sort_order: int = 0,
due_at: Optional[datetime] = None,
user_id: Optional[str] = None,
) -> dict[str, Any]:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
_validate_status(status)
action = _get_action_or_raise(tenant_id=tenant_id, action_id=action_id)
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
_validate_roadmap_item_for_action(
cur,
tenant_id=tenant_id,
initiative_id=action["initiative_id"],
roadmap_item_id=roadmap_item_id,
)
cur.execute(
f"""
INSERT INTO tasks (
tenant_id, action_id, title, description, status,
roadmap_item_id, sort_order, due_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING {_TASK_COLUMNS}
""",
(
tenant_id,
action_id,
title,
description,
status,
roadmap_item_id,
sort_order,
due_at,
),
)
row = _serialize_row(dict(cur.fetchone()))
conn.commit()
finally:
conn.close()
log_audit(
"task.created",
user_id=user_id,
tenant_id=tenant_id,
details={"task_id": row["id"], "action_id": action_id, "title": title},
)
return row
def list_tasks_for_action(*, tenant_id: str, action_id: str) -> list[dict[str, Any]]:
_get_action_or_raise(tenant_id=tenant_id, action_id=action_id)
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
SELECT {_TASK_COLUMNS}
FROM tasks
WHERE tenant_id = %s AND action_id = %s
ORDER BY sort_order ASC, created_at ASC
""",
(tenant_id, action_id),
)
return [_serialize_row(dict(r)) for r in cur.fetchall()]
finally:
conn.close()
def get_task(*, tenant_id: str, task_id: str) -> Optional[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"SELECT {_TASK_COLUMNS} FROM tasks WHERE id = %s AND tenant_id = %s",
(task_id, tenant_id),
)
row = cur.fetchone()
return _serialize_row(dict(row)) if row else None
finally:
conn.close()
def update_task(
*,
tenant_id: str,
task_id: str,
user_id: Optional[str] = None,
title: Optional[str] = None,
description: Optional[str] = None,
status: Optional[TaskStatus] = None,
roadmap_item_id: Optional[str] = None,
clear_roadmap_item: bool = False,
sort_order: Optional[int] = None,
due_at: Optional[datetime] = None,
clear_due_at: bool = False,
) -> Optional[dict[str, Any]]:
existing = get_task(tenant_id=tenant_id, task_id=task_id)
if not existing:
return None
action = _get_action_or_raise(tenant_id=tenant_id, action_id=existing["action_id"])
updates: list[str] = []
params: list[Any] = []
if title is not None:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
updates.append("title = %s")
params.append(title)
if description is not None:
updates.append("description = %s")
params.append(description)
if status is not None:
_validate_status(status)
updates.append("status = %s")
params.append(status)
if clear_roadmap_item:
updates.append("roadmap_item_id = NULL")
elif roadmap_item_id is not None:
conn = get_connection()
try:
with conn.cursor() as cur:
_validate_roadmap_item_for_action(
cur,
tenant_id=tenant_id,
initiative_id=action["initiative_id"],
roadmap_item_id=roadmap_item_id,
)
finally:
conn.close()
updates.append("roadmap_item_id = %s")
params.append(roadmap_item_id)
if sort_order is not None:
updates.append("sort_order = %s")
params.append(sort_order)
if clear_due_at:
updates.append("due_at = NULL")
elif due_at is not None:
updates.append("due_at = %s")
params.append(due_at)
if not updates:
return existing
updates.append("updated_at = NOW()")
params.extend([task_id, tenant_id])
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
UPDATE tasks SET {", ".join(updates)}
WHERE id = %s AND tenant_id = %s
RETURNING {_TASK_COLUMNS}
""",
params,
)
row = cur.fetchone()
if not row:
return None
result = _serialize_row(dict(row))
conn.commit()
finally:
conn.close()
log_audit(
"task.updated",
user_id=user_id,
tenant_id=tenant_id,
details={"task_id": task_id},
)
return result
def delete_task(*, tenant_id: str, task_id: str, user_id: Optional[str] = None) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM tasks WHERE id = %s AND tenant_id = %s RETURNING id",
(task_id, tenant_id),
)
deleted = cur.fetchone() is not None
conn.commit()
finally:
conn.close()
if deleted:
log_audit(
"task.deleted",
user_id=user_id,
tenant_id=tenant_id,
details={"task_id": task_id},
)
return deleted

View File

@ -0,0 +1,75 @@
"""AP1.5 — Project, Task, Action.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_project_and_action_hierarchy(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
project = client.post(
f"/api/initiatives/{initiative_id}/projects",
json={"title": "Karate Grundlagen", "description": "8 Fähigkeiten"},
headers=_auth(token),
)
assert project.status_code == 201
project_id = project.json()["id"]
action = client.post(
f"/api/initiatives/{initiative_id}/actions",
json={"title": "Kata Heian Shodan", "project_id": project_id},
headers=_auth(token),
)
assert action.status_code == 201
assert action.json()["project_id"] == project_id
task = client.post(
f"/api/actions/{action.json()['id']}/tasks",
json={"title": "Standfußarbeit üben"},
headers=_auth(token),
)
assert task.status_code == 201
assert task.json()["status"] == "open"
listed = client.get(
f"/api/actions/{action.json()['id']}/tasks",
headers=_auth(token),
)
assert listed.status_code == 200
assert len(listed.json()) == 1
projects = client.get(
f"/api/initiatives/{initiative_id}/projects",
headers=_auth(token),
)
assert projects.status_code == 200
assert len(projects.json()) == 1
def test_initiative_without_projects_still_works(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
action = client.post(
f"/api/initiatives/{initiative_id}/actions",
json={"title": "Freistehende Aufgabe"},
headers=_auth(token),
)
assert action.status_code == 201
assert action.json().get("project_id") is None
projects = client.get(
f"/api/initiatives/{initiative_id}/projects",
headers=_auth(token),
)
assert projects.status_code == 200
assert projects.json() == []

View File

@ -45,6 +45,8 @@ def test_registry_contains_initial_capabilities():
"kairo.recurring.manage",
"kairo.roadmap.reopen",
"kairo.roadmap.reopen.emergency",
"kairo.project.read",
"kairo.project.manage",
}
@ -56,7 +58,7 @@ def test_sync_is_idempotent():
try:
with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM capabilities")
assert cur.fetchone()[0] == 35
assert cur.fetchone()[0] == 37
cur.execute("SELECT COUNT(*) FROM role_capability_grants")
assert cur.fetchone()[0] >= 5
finally:

View File

@ -1,3 +1,3 @@
APP_VERSION = "0.13.1-ap1.4b"
DB_SCHEMA_VERSION = "012"
APP_VERSION = "0.14.0-ap1.5"
DB_SCHEMA_VERSION = "013"
APP_NAME = "jinkendo-kairo"

View File

@ -1,7 +1,7 @@
# Kairo — Implementation Truth Table v0.1
**Status:** living document — bei jedem AP aktualisieren
**Stand:** 2026-07-06 (nach AP1.4b)
**Stand:** 2026-07-06 (nach AP1.5)
**Zweck:** Ehrliche Trennung von **implementiert**, **teilweise**, **nur API/Schema**, **nur Dokumentiert**
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 |
| Auth / Session | ✓ | |
| Audit (Auth/Admin) | ◐ | Nicht für alle OM-Events |
| Migrationen nummeriert | ✓ | Schema 012 |
| Migrationen nummeriert | ✓ | Schema 013 |
---
@ -39,8 +39,8 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| Element | Stand | Anmerkung |
|---------|-------|-----------|
| Initiative | ✓ | |
| Project | ○ | Schema/API teils; **keine GUI** |
| Action | ✓ | UI-Label „Maßnahme“ |
| Project | ◐ | AP1.5 GUI + API |
| Action | ✓ | UI: Arbeitspaket (interim Maßnahme) |
| ActionAssignment | ✓ | |
| BacklogItem | ✓ | |
| Blocker | ✓ | `action_id` optional |
@ -49,7 +49,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| Decision | ✓ | |
| Review | ✓ | |
| RecurringElement | ✓ | |
| Task (unter Action) | ✗ | |
| Task (unter Action) | ◐ | AP1.5 minimal GUI |
| Roadmap | ◐ | 1 pro Initiative; Migration 010 |
| RoadmapItem | ◐ | Verify über Checkliste AP1.4b |
| RoadmapItem Dependencies | ◐ | requires/blocks/related |

View File

@ -0,0 +1,23 @@
import { apiFetch } from './client.js'
export function listInitiativeProjects(initiativeId) {
return apiFetch(`/api/initiatives/${initiativeId}/projects`)
}
export function createInitiativeProject(initiativeId, body) {
return apiFetch(`/api/initiatives/${initiativeId}/projects`, {
method: 'POST',
body: JSON.stringify(body),
})
}
export function updateProject(projectId, body) {
return apiFetch(`/api/projects/${projectId}`, {
method: 'PATCH',
body: JSON.stringify(body),
})
}
export function deleteProject(projectId) {
return apiFetch(`/api/projects/${projectId}`, { method: 'DELETE' })
}

23
frontend/src/api/tasks.js Normal file
View File

@ -0,0 +1,23 @@
import { apiFetch } from './client.js'
export function listActionTasks(actionId) {
return apiFetch(`/api/actions/${actionId}/tasks`)
}
export function createActionTask(actionId, body) {
return apiFetch(`/api/actions/${actionId}/tasks`, {
method: 'POST',
body: JSON.stringify(body),
})
}
export function updateTask(taskId, body) {
return apiFetch(`/api/tasks/${taskId}`, {
method: 'PATCH',
body: JSON.stringify(body),
})
}
export function deleteTask(taskId) {
return apiFetch(`/api/tasks/${taskId}`, { method: 'DELETE' })
}

View File

@ -11,6 +11,7 @@ function isoToLocalInput(iso) {
export function ActionForm({
initial = {},
projects = [],
actors = [],
actorsLoading = false,
actorsError = null,
@ -31,6 +32,7 @@ export function ActionForm({
status: form.status.value,
priority: form.priority.value,
due_at: form.due_at.value ? new Date(form.due_at.value).toISOString() : null,
project_id: form.project_id?.value || undefined,
assigned_actor_ids: selected,
})
}
@ -75,6 +77,19 @@ export function ActionForm({
defaultValue={isoToLocalInput(initial.due_at)}
/>
</label>
{projects.length > 0 && (
<label>
Projekt (optional)
<select name="project_id" defaultValue={initial.project_id || ''}>
<option value=""> keins </option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.title}
</option>
))}
</select>
</label>
)}
<ActorSelect
actors={actors}
defaultSelected={defaultActors}

View File

@ -5,6 +5,7 @@ import { EmptyState } from './EmptyState.jsx'
export function InitiativeActionsHub({
initiativeId,
actions,
projects = [],
actionContextById,
hideDone,
onHideDoneChange,
@ -30,9 +31,10 @@ export function InitiativeActionsHub({
<section className="card initiative-actions-hub">
<div className="section-header">
<div>
<h2>Maßnahmen operative Arbeit</h2>
<h2>Arbeitspakete</h2>
<p className="section-lead muted">
Blocker, Nachweise und Reviews hängen an der Maßnahme, nicht lose im Vorhaben.
Committete operative Einheit Aufgaben im Detail. Blocker, Nachweise und Reviews
hängen am Arbeitspaket.
</p>
</div>
<div className="section-actions">
@ -50,7 +52,7 @@ export function InitiativeActionsHub({
className="btn btn-primary btn-block-mobile"
onClick={onToggleForm}
>
{showForm ? 'Abbrechen' : 'Maßnahme anlegen'}
{showForm ? 'Abbrechen' : 'Arbeitspaket'}
</button>
)}
</div>
@ -59,6 +61,7 @@ export function InitiativeActionsHub({
{showForm && canManage && (
<div className="inline-form-block">
<ActionForm
projects={projects}
actors={actors}
actorsLoading={actorsLoading}
actorsError={actorsError}
@ -67,13 +70,13 @@ export function InitiativeActionsHub({
onSubmit={onCreateAction}
onCancel={onToggleForm}
busy={formBusy}
submitLabel="Maßnahme anlegen"
submitLabel="Arbeitspaket anlegen"
/>
</div>
)}
{actions.length === 0 && (
<EmptyState message="Noch keine Maßnahmen — lege die erste operative Maßnahme an oder wandle Backlog um." />
<EmptyState message="Noch keine Arbeitspakete — lege operative Schritte an oder wandle Backlog um." />
)}
<div className="action-hub-grid">

View File

@ -2,7 +2,7 @@ import { NavLink, useParams } from 'react-router-dom'
const TABS = [
{ to: '', label: 'Steuerung', end: true },
{ to: 'plan', label: 'Plan' },
{ to: 'plan', label: 'Zielzustände' },
{ to: 'execution', label: 'Ausführung' },
{ to: 'inbox', label: 'Eingang' },
{ to: 'journey', label: 'Nachvollziehbarkeit' },

View File

@ -0,0 +1,124 @@
import { useState } from 'react'
import { EmptyState } from './EmptyState.jsx'
import { StatusBadge } from './StatusBadge.jsx'
export function ProjectsSection({
projects,
selectedProjectId,
onSelectProject,
canManage,
onCreate,
onDelete,
busy,
}) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [showForm, setShowForm] = useState(false)
async function handleSubmit(e) {
e.preventDefault()
if (!title.trim()) return
await onCreate({
title: title.trim(),
description: description.trim(),
})
setTitle('')
setDescription('')
setShowForm(false)
}
return (
<section className="card projects-section">
<div className="section-header">
<div>
<h2>Projekte</h2>
<p className="section-lead muted">
Operative Struktur Arbeitspakete gruppieren. Zielzustände (Gates) sind optional.
</p>
</div>
{canManage && (
<button
type="button"
className="btn btn-primary btn-block-mobile"
onClick={() => setShowForm((v) => !v)}
>
{showForm ? 'Abbrechen' : 'Projekt'}
</button>
)}
</div>
{showForm && canManage && (
<form className="inline-form-block" onSubmit={handleSubmit}>
<label>
Titel
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={255}
required
placeholder="z. B. Phase 1 — Grundlagen"
/>
</label>
<label>
Beschreibung
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
/>
</label>
<button type="submit" className="btn btn-primary" disabled={busy}>
Anlegen
</button>
</form>
)}
{projects.length === 0 && (
<EmptyState message="Noch keine Projekte — optional. Du kannst auch direkt Arbeitspakete anlegen." />
)}
<ul className="item-list project-filter-list">
<li className="list-item card-list-item">
<button
type="button"
className={`btn btn-secondary${selectedProjectId === '' ? ' btn-primary' : ''}`}
onClick={() => onSelectProject('')}
>
Alle Arbeitspakete
</button>
</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>
</section>
)
}

View File

@ -67,10 +67,10 @@ export function RoadmapPlanSection({
<section className="card">
<div className="section-header">
<div>
<h2>Plan</h2>
<h2>Zielzustände (Gates)</h2>
<p className="section-lead muted">
Roadmap-Elemente methodenneutral Gates, Reifegrade oder Review-Punkte.
Erreicht nur über Verify (Evidence, Review oder Decision).
Überprüfbare Zielpunkte optional. Operative Planung läuft unter Ausführung.
Erreicht nur über Verify (Kriterien, Evidence, Review).
</p>
</div>
{canManage && (

View File

@ -0,0 +1,151 @@
import { useCallback, useEffect, useState } from 'react'
import {
createActionTask,
deleteTask,
listActionTasks,
updateTask,
} from '../api/tasks.js'
import { TASK_STATUSES, TASK_STATUS_LABELS } from '../constants/status.js'
import { EmptyState } from './EmptyState.jsx'
export function TasksSection({ actionId, canManage }) {
const [tasks, setTasks] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const [title, setTitle] = useState('')
const load = useCallback(async () => {
if (!actionId) return
setLoading(true)
setError(null)
try {
setTasks(await listActionTasks(actionId))
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [actionId])
useEffect(() => {
load()
}, [load])
async function handleCreate(e) {
e.preventDefault()
if (!title.trim()) return
setBusy(true)
try {
await createActionTask(actionId, { title: title.trim() })
setTitle('')
await load()
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
async function handleStatus(taskId, status) {
setBusy(true)
try {
await updateTask(taskId, { status })
await load()
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
async function handleDelete(taskId) {
setBusy(true)
try {
await deleteTask(taskId)
await load()
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
if (loading) return <p className="muted">Tasks werden geladen</p>
return (
<section className="card tasks-section">
<div className="section-header">
<div>
<h3>Aufgaben</h3>
<p className="section-lead muted">
Kleinste ausführbare Schritte später Zuordnung zu Gates (Zielerreichung).
</p>
</div>
</div>
{error && <p className="error">{error}</p>}
{canManage && (
<form className="inline-form-block task-inline-form" onSubmit={handleCreate}>
<label>
Neue Aufgabe
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={255}
required
placeholder="Konkreter nächster Schritt"
/>
</label>
<button type="submit" className="btn btn-primary" disabled={busy}>
Hinzufügen
</button>
</form>
)}
{tasks.length === 0 && (
<EmptyState message="Noch keine Aufgaben — zerlege das Arbeitspaket in umsetzbare Schritte." />
)}
<ul className="item-list task-list">
{tasks.map((task) => (
<li key={task.id} className="list-item card-list-item">
<div className="list-item-main">
<strong>{task.title}</strong>
</div>
<div className="list-item-meta action-controls">
{canManage ? (
<select
className="inline-select"
value={task.status}
disabled={busy}
onChange={(e) => handleStatus(task.id, e.target.value)}
aria-label="Task-Status"
>
{TASK_STATUSES.map((s) => (
<option key={s} value={s}>
{TASK_STATUS_LABELS[s]}
</option>
))}
</select>
) : (
<span>{TASK_STATUS_LABELS[task.status] || task.status}</span>
)}
{canManage && (
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() => handleDelete(task.id)}
>
Entfernen
</button>
)}
</div>
</li>
))}
</ul>
</section>
)
}

View File

@ -15,6 +15,7 @@ export const EVIDENCE_STATUSES = ['submitted', 'accepted', 'rejected']
export const DECISION_STATUSES = ['proposed', 'decided', 'superseded']
export const REVIEW_STATUSES = ['planned', 'completed', 'skipped']
export const RECURRING_STATUSES = ['active', 'paused', 'ended']
export const TASK_STATUSES = ['open', 'in_progress', 'done', 'discarded']
export const PRIORITIES = ['low', 'normal', 'high']
export const OPEN_ACTION_STATUSES = ['open', 'ready', 'in_progress', 'blocked', 'review_required']
@ -114,6 +115,13 @@ export const RECURRING_STATUS_LABELS = {
ended: 'Beendet',
}
export const TASK_STATUS_LABELS = {
open: 'Offen',
in_progress: 'In Arbeit',
done: 'Erledigt',
discarded: 'Verworfen',
}
export const PRIORITY_LABELS = {
low: 'Niedrig',
normal: 'Normal',

View File

@ -34,6 +34,11 @@ import {
deleteRoadmapItem,
verifyRoadmapItemReached,
} from '../api/roadmap.js'
import {
listInitiativeProjects,
createInitiativeProject,
deleteProject,
} from '../api/projects.js'
import {
listInitiativeEvidence,
createInitiativeEvidence,
@ -76,6 +81,8 @@ export function InitiativeOperationsProvider({ children }) {
const [blockers, setBlockers] = useState([])
const [backlogItems, setBacklogItems] = useState([])
const [roadmapItems, setRoadmapItems] = useState([])
const [projects, setProjects] = useState([])
const [selectedProjectId, setSelectedProjectId] = useState('')
const [evidenceItems, setEvidenceItems] = useState([])
const [decisions, setDecisions] = useState([])
const [reviews, setReviews] = useState([])
@ -112,6 +119,7 @@ export function InitiativeOperationsProvider({ children }) {
loads.push(listInitiativeBlockers(id).then(setBlockers).catch(() => setBlockers([])))
loads.push(listInitiativeBacklog(id).then(setBacklogItems).catch(() => setBacklogItems([])))
loads.push(listInitiativeRoadmapItems(id).then(setRoadmapItems).catch(() => setRoadmapItems([])))
loads.push(listInitiativeProjects(id).then(setProjects).catch(() => setProjects([])))
loads.push(listInitiativeEvidence(id).then(setEvidenceItems).catch(() => setEvidenceItems([])))
loads.push(listInitiativeDecisions(id).then(setDecisions).catch(() => setDecisions([])))
loads.push(listInitiativeReviews(id).then(setReviews).catch(() => setReviews([])))
@ -147,11 +155,16 @@ export function InitiativeOperationsProvider({ children }) {
}, [load])
const visibleActions = useMemo(
() =>
hideDone
() => {
let list = hideDone
? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded')
: actions,
[actions, hideDone]
: actions
if (selectedProjectId) {
list = list.filter((a) => a.project_id === selectedProjectId)
}
return list
},
[actions, hideDone, selectedProjectId]
)
const actionContextById = useMemo(
@ -185,7 +198,11 @@ export function InitiativeOperationsProvider({ children }) {
: context?.actor?.id
? [context.actor.id]
: []
await createInitiativeAction(id, { ...payload, assigned_actor_ids: assigned })
await createInitiativeAction(id, {
...payload,
project_id: payload.project_id || selectedProjectId || undefined,
assigned_actor_ids: assigned,
})
setShowActionForm(false)
await load()
} catch (err) {
@ -205,6 +222,8 @@ export function InitiativeOperationsProvider({ children }) {
priority: payload.priority,
due_at: payload.due_at,
clear_due_at: !payload.due_at,
project_id: payload.project_id,
clear_project: payload.project_id === '' || payload.project_id === null,
})
if (capabilities.has('kairo.action.manage')) {
await setActionAssignments(actionId, payload.assigned_actor_ids || [])
@ -386,6 +405,28 @@ export function InitiativeOperationsProvider({ children }) {
}
}
async function handleCreateProject(body) {
setFormBusy(true)
try {
await createInitiativeProject(id, body)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleDeleteProject(projectId) {
try {
await deleteProject(projectId)
if (selectedProjectId === projectId) setSelectedProjectId('')
await load()
} catch (err) {
setError(err.message)
}
}
const milestones = useMemo(
() => roadmapItems.filter((item) => item.item_type === 'milestone'),
[roadmapItems]
@ -520,6 +561,9 @@ export function InitiativeOperationsProvider({ children }) {
unlinkedBlockers,
backlogItems,
roadmapItems,
projects,
selectedProjectId,
setSelectedProjectId,
milestones,
evidenceItems,
decisions,
@ -567,7 +611,9 @@ export function InitiativeOperationsProvider({ children }) {
handleDeleteRoadmapItem,
handleCreateMilestone: handleCreateRoadmapItem,
handleMilestoneStatus: handleRoadmapItemStatus,
handleDeleteMilestone: handleDeleteRoadmapItem,
handleDeleteMilestone: handleDeleteRoadmapItem,
handleCreateProject,
handleDeleteProject,
handleCreateEvidence,
handleEvidenceStatus,
handleDeleteEvidence,

View File

@ -2,10 +2,12 @@ import { useCallback, useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { getAction } from '../api/actions.js'
import { getInitiativeSteeringSnapshot } from '../api/initiatives.js'
import { listInitiativeProjects } from '../api/projects.js'
import { updateAction, setActionAssignments } from '../api/actions.js'
import { createInitiativeBlocker } from '../api/blockers.js'
import { ActionHubCard } from '../components/ActionHubCard.jsx'
import { ActionForm } from '../components/ActionForm.jsx'
import { TasksSection } from '../components/TasksSection.jsx'
import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx'
import { useInitiativeOperations } from '../context/InitiativeOperationsContext.jsx'
@ -24,11 +26,13 @@ function ActionDetailBody({
formBusy,
capabilities,
actorsState,
projects = [],
}) {
if (editing) {
return (
<ActionForm
initial={action}
projects={projects}
actors={actorsState.actors}
actorsLoading={actorsState.loading}
actorsError={actorsState.error}
@ -43,25 +47,31 @@ function ActionDetailBody({
}
return (
<ActionHubCard
action={action}
context={context}
editing={false}
onEdit={onEdit}
onCancelEdit={onCancelEdit}
onSubmitEdit={onSubmit}
onQuickStatus={onQuickStatus}
onCreateBlocker={onCreateBlocker}
canManage={capabilities.has('kairo.action.manage')}
canManageBlocker={capabilities.has('kairo.blocker.manage')}
formBusy={formBusy}
actors={actorsState.actors}
actorsLoading={actorsState.loading}
actorsError={actorsState.error}
actorsUsedFallback={actorsState.usedFallback}
onReloadActors={actorsState.reload}
detailMode
/>
<>
<ActionHubCard
action={action}
context={context}
editing={false}
onEdit={onEdit}
onCancelEdit={onCancelEdit}
onSubmitEdit={onSubmit}
onQuickStatus={onQuickStatus}
onCreateBlocker={onCreateBlocker}
canManage={capabilities.has('kairo.action.manage')}
canManageBlocker={capabilities.has('kairo.blocker.manage')}
formBusy={formBusy}
actors={actorsState.actors}
actorsLoading={actorsState.loading}
actorsError={actorsState.error}
actorsUsedFallback={actorsState.usedFallback}
onReloadActors={actorsState.reload}
detailMode
/>
<TasksSection
actionId={action.id}
canManage={capabilities.has('kairo.action.manage')}
/>
</>
)
}
@ -70,6 +80,7 @@ function ActionDetailStandalone() {
const { capabilities } = useCapabilities()
const actorsState = useActors()
const [action, setAction] = useState(null)
const [projects, setProjects] = useState([])
const [context, setContext] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
@ -82,7 +93,11 @@ function ActionDetailStandalone() {
try {
const actionData = await getAction(actionId)
setAction(actionData)
const snap = await getInitiativeSteeringSnapshot(actionData.initiative_id)
const [snap, projectData] = await Promise.all([
getInitiativeSteeringSnapshot(actionData.initiative_id),
listInitiativeProjects(actionData.initiative_id).catch(() => []),
])
setProjects(projectData)
setContext((snap.actions || []).find((a) => a.id === actionId) || null)
} catch (err) {
setError(err.message)
@ -188,6 +203,7 @@ function ActionDetailStandalone() {
formBusy={formBusy}
capabilities={capabilities}
actorsState={actorsState}
projects={projects}
/>
</section>
</div>
@ -228,6 +244,7 @@ function ActionDetailNested() {
usedFallback: ops.actorsUsedFallback,
reload: ops.reloadActors,
}}
projects={ops.projects}
/>
</section>
)

View File

@ -1,11 +1,15 @@
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import { InitiativeActionsHub } from '../../components/InitiativeActionsHub.jsx'
import { ProjectsSection } from '../../components/ProjectsSection.jsx'
import { BlockersSection } from '../../components/BlockersSection.jsx'
export function InitiativeExecutionPage() {
const ops = useInitiativeOperations()
const {
visibleActions,
projects,
selectedProjectId,
setSelectedProjectId,
actionContextById,
hideDone,
setHideDone,
@ -24,9 +28,20 @@ export function InitiativeExecutionPage() {
<>
{error && <p className="error">{error}</p>}
<ProjectsSection
projects={projects}
selectedProjectId={selectedProjectId}
onSelectProject={setSelectedProjectId}
canManage={capabilities.has('kairo.project.manage')}
onCreate={ops.handleCreateProject}
onDelete={ops.handleDeleteProject}
busy={formBusy}
/>
<InitiativeActionsHub
initiativeId={initiativeId}
actions={visibleActions}
projects={projects}
actionContextById={actionContextById}
hideDone={hideDone}
onHideDoneChange={setHideDone}
@ -56,7 +71,7 @@ export function InitiativeExecutionPage() {
<BlockersSection
blockers={unlinkedBlockers}
heading="Vorhaben-Blocker"
lead="Ohne Maßnahmenbezug — verknüpfte Blocker erscheinen am Arbeitspaket."
lead="Ohne Arbeitspaketbezug — verknüpfte Blocker erscheinen am Arbeitspaket."
emptyMessage="Keine vorhabenweiten Blocker."
canManage={capabilities.has('kairo.blocker.manage')}
onCreate={ops.handleCreateBlocker}

View File

@ -131,7 +131,7 @@ export function RoadmapItemDetailPage() {
return (
<section className="card roadmap-item-detail">
<p className="breadcrumb muted">
<Link to={`/initiatives/${initiativeId}/plan`}>Plan</Link>
<Link to={`/initiatives/${initiativeId}/plan`}>Zielzustände</Link>
{' · '}
{item.title}
</p>