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
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:
parent
2e7651812a
commit
6eeddfdc76
|
|
@ -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)
|
||||
|
|
|
|||
45
backend/migrations/013_projects_and_tasks.sql
Normal file
45
backend/migrations/013_projects_and_tasks.sql
Normal 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);
|
||||
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
119
backend/routers/projects.py
Normal 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
69
backend/routers/tasks.py
Normal 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")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
283
backend/services/projects.py
Normal file
283
backend/services/projects.py
Normal 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
273
backend/services/tasks.py
Normal 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
|
||||
75
backend/tests/test_ap15_hierarchy.py
Normal file
75
backend/tests/test_ap15_hierarchy.py
Normal 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() == []
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
23
frontend/src/api/projects.js
Normal file
23
frontend/src/api/projects.js
Normal 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
23
frontend/src/api/tasks.js
Normal 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' })
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
124
frontend/src/components/ProjectsSection.jsx
Normal file
124
frontend/src/components/ProjectsSection.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
151
frontend/src/components/TasksSection.jsx
Normal file
151
frontend/src/components/TasksSection.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user