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,
|
initiatives,
|
||||||
me,
|
me,
|
||||||
milestones,
|
milestones,
|
||||||
|
projects,
|
||||||
roadmap,
|
roadmap,
|
||||||
prompts,
|
prompts,
|
||||||
recurring,
|
recurring,
|
||||||
reviews,
|
reviews,
|
||||||
steering,
|
steering,
|
||||||
|
tasks,
|
||||||
workspace,
|
workspace,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -83,6 +85,9 @@ app.include_router(prompts.router)
|
||||||
app.include_router(config.router)
|
app.include_router(config.router)
|
||||||
app.include_router(initiatives.router)
|
app.include_router(initiatives.router)
|
||||||
app.include_router(actions.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(blockers.router)
|
||||||
app.include_router(backlog.router)
|
app.include_router(backlog.router)
|
||||||
app.include_router(milestones.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"),),
|
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 fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from services import actions as action_service
|
from services import actions as action_service
|
||||||
|
from services import tasks as task_service
|
||||||
from tenant_context import TenantContext
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/actions", tags=["actions"])
|
router = APIRouter(prefix="/api/actions", tags=["actions"])
|
||||||
|
|
@ -24,6 +25,17 @@ class ActionUpdateRequest(BaseModel):
|
||||||
priority: Optional[Literal["low", "normal", "high"]] = None
|
priority: Optional[Literal["low", "normal", "high"]] = None
|
||||||
due_at: Optional[str] = None
|
due_at: Optional[str] = None
|
||||||
clear_due_at: bool = False
|
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):
|
class ActionAssignmentsRequest(BaseModel):
|
||||||
|
|
@ -40,6 +52,47 @@ def list_my_open_actions(
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
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}")
|
@router.get("/{action_id}")
|
||||||
def get_action(
|
def get_action(
|
||||||
action_id: str,
|
action_id: str,
|
||||||
|
|
@ -74,6 +127,8 @@ def update_action(
|
||||||
priority=body.priority,
|
priority=body.priority,
|
||||||
due_at=due_at,
|
due_at=due_at,
|
||||||
clear_due_at=body.clear_due_at,
|
clear_due_at=body.clear_due_at,
|
||||||
|
project_id=body.project_id,
|
||||||
|
clear_project=body.clear_project,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ class ActionCreateRequest(BaseModel):
|
||||||
] = "open"
|
] = "open"
|
||||||
priority: Literal["low", "normal", "high"] = "normal"
|
priority: Literal["low", "normal", "high"] = "normal"
|
||||||
due_at: Optional[str] = None
|
due_at: Optional[str] = None
|
||||||
|
project_id: Optional[str] = None
|
||||||
assigned_actor_ids: list[str] = Field(default_factory=list)
|
assigned_actor_ids: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -249,6 +250,7 @@ def create_initiative_action(
|
||||||
status=body.status,
|
status=body.status,
|
||||||
priority=body.priority,
|
priority=body.priority,
|
||||||
due_at=due_at,
|
due_at=due_at,
|
||||||
|
project_id=body.project_id,
|
||||||
assigned_actor_ids=body.assigned_actor_ids,
|
assigned_actor_ids=body.assigned_actor_ids,
|
||||||
user_id=ctx.user_id,
|
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 = """
|
_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
|
status, priority, due_at, created_at, updated_at
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
result = dict(row)
|
result = dict(row)
|
||||||
for key in ("id", "tenant_id", "initiative_id", "owner_actor_id"):
|
for key in ("id", "tenant_id", "initiative_id", "project_id", "owner_actor_id"):
|
||||||
if result.get(key):
|
if result.get(key):
|
||||||
result[key] = str(result[key])
|
result[key] = str(result[key])
|
||||||
if result.get("created_at"):
|
if result.get("created_at"):
|
||||||
|
|
@ -99,6 +99,27 @@ def _actor_in_tenant(*, tenant_id: str, actor_id: str) -> bool:
|
||||||
conn.close()
|
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(
|
def create_action(
|
||||||
*,
|
*,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
|
|
@ -108,6 +129,7 @@ def create_action(
|
||||||
status: ActionStatus = "open",
|
status: ActionStatus = "open",
|
||||||
priority: str = "normal",
|
priority: str = "normal",
|
||||||
due_at: Optional[Any] = None,
|
due_at: Optional[Any] = None,
|
||||||
|
project_id: Optional[str] = None,
|
||||||
assigned_actor_ids: Optional[list[str]] = None,
|
assigned_actor_ids: Optional[list[str]] = None,
|
||||||
user_id: Optional[str] = None,
|
user_id: Optional[str] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
|
@ -118,6 +140,9 @@ def create_action(
|
||||||
_validate_priority(priority)
|
_validate_priority(priority)
|
||||||
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
||||||
raise ValueError("Initiative nicht gefunden")
|
raise ValueError("Initiative nicht gefunden")
|
||||||
|
_validate_project_in_initiative(
|
||||||
|
tenant_id=tenant_id, initiative_id=initiative_id, project_id=project_id
|
||||||
|
)
|
||||||
|
|
||||||
assigned_actor_ids = assigned_actor_ids or []
|
assigned_actor_ids = assigned_actor_ids or []
|
||||||
for actor_id in assigned_actor_ids:
|
for actor_id in assigned_actor_ids:
|
||||||
|
|
@ -130,12 +155,13 @@ def create_action(
|
||||||
cur.execute(
|
cur.execute(
|
||||||
f"""
|
f"""
|
||||||
INSERT INTO actions (
|
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}
|
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()))
|
row = _serialize_row(dict(cur.fetchone()))
|
||||||
for actor_id in assigned_actor_ids:
|
for actor_id in assigned_actor_ids:
|
||||||
|
|
@ -221,6 +247,8 @@ def update_action(
|
||||||
priority: Optional[str] = None,
|
priority: Optional[str] = None,
|
||||||
due_at: Optional[Any] = None,
|
due_at: Optional[Any] = None,
|
||||||
clear_due_at: bool = False,
|
clear_due_at: bool = False,
|
||||||
|
project_id: Optional[str] = None,
|
||||||
|
clear_project: bool = False,
|
||||||
) -> Optional[dict[str, Any]]:
|
) -> Optional[dict[str, Any]]:
|
||||||
existing = get_action(tenant_id=tenant_id, action_id=action_id)
|
existing = get_action(tenant_id=tenant_id, action_id=action_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
|
|
@ -252,6 +280,16 @@ def update_action(
|
||||||
elif due_at is not None:
|
elif due_at is not None:
|
||||||
updates.append("due_at = %s")
|
updates.append("due_at = %s")
|
||||||
params.append(due_at)
|
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:
|
if not updates:
|
||||||
return existing
|
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.recurring.manage",
|
||||||
"kairo.roadmap.reopen",
|
"kairo.roadmap.reopen",
|
||||||
"kairo.roadmap.reopen.emergency",
|
"kairo.roadmap.reopen.emergency",
|
||||||
|
"kairo.project.read",
|
||||||
|
"kairo.project.manage",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -56,7 +58,7 @@ def test_sync_is_idempotent():
|
||||||
try:
|
try:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute("SELECT COUNT(*) FROM capabilities")
|
cur.execute("SELECT COUNT(*) FROM capabilities")
|
||||||
assert cur.fetchone()[0] == 35
|
assert cur.fetchone()[0] == 37
|
||||||
cur.execute("SELECT COUNT(*) FROM role_capability_grants")
|
cur.execute("SELECT COUNT(*) FROM role_capability_grants")
|
||||||
assert cur.fetchone()[0] >= 5
|
assert cur.fetchone()[0] >= 5
|
||||||
finally:
|
finally:
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
APP_VERSION = "0.13.1-ap1.4b"
|
APP_VERSION = "0.14.0-ap1.5"
|
||||||
DB_SCHEMA_VERSION = "012"
|
DB_SCHEMA_VERSION = "013"
|
||||||
APP_NAME = "jinkendo-kairo"
|
APP_NAME = "jinkendo-kairo"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Kairo — Implementation Truth Table v0.1
|
# Kairo — Implementation Truth Table v0.1
|
||||||
|
|
||||||
**Status:** living document — bei jedem AP aktualisieren
|
**Status:** living document — bei jedem AP aktualisieren
|
||||||
**Stand:** 2026-07-06 (nach AP1.4b)
|
**Stand:** 2026-07-06 (nach AP1.5)
|
||||||
**Zweck:** Ehrliche Trennung von **implementiert**, **teilweise**, **nur API/Schema**, **nur Dokumentiert**
|
**Zweck:** Ehrliche Trennung von **implementiert**, **teilweise**, **nur API/Schema**, **nur Dokumentiert**
|
||||||
|
|
||||||
Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|
Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|
||||||
|
|
@ -30,7 +30,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|
||||||
| Capabilities | ✓ | Keine Objekt-Sichtbarkeit |
|
| Capabilities | ✓ | Keine Objekt-Sichtbarkeit |
|
||||||
| Auth / Session | ✓ | |
|
| Auth / Session | ✓ | |
|
||||||
| Audit (Auth/Admin) | ◐ | Nicht für alle OM-Events |
|
| Audit (Auth/Admin) | ◐ | Nicht für alle OM-Events |
|
||||||
| Migrationen nummeriert | ✓ | Schema 012 |
|
| Migrationen nummeriert | ✓ | Schema 013 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -39,8 +39,8 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|
||||||
| Element | Stand | Anmerkung |
|
| Element | Stand | Anmerkung |
|
||||||
|---------|-------|-----------|
|
|---------|-------|-----------|
|
||||||
| Initiative | ✓ | |
|
| Initiative | ✓ | |
|
||||||
| Project | ○ | Schema/API teils; **keine GUI** |
|
| Project | ◐ | AP1.5 GUI + API |
|
||||||
| Action | ✓ | UI-Label „Maßnahme“ |
|
| Action | ✓ | UI: Arbeitspaket (interim Maßnahme) |
|
||||||
| ActionAssignment | ✓ | |
|
| ActionAssignment | ✓ | |
|
||||||
| BacklogItem | ✓ | |
|
| BacklogItem | ✓ | |
|
||||||
| Blocker | ✓ | `action_id` optional |
|
| Blocker | ✓ | `action_id` optional |
|
||||||
|
|
@ -49,7 +49,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|
||||||
| Decision | ✓ | |
|
| Decision | ✓ | |
|
||||||
| Review | ✓ | |
|
| Review | ✓ | |
|
||||||
| RecurringElement | ✓ | |
|
| RecurringElement | ✓ | |
|
||||||
| Task (unter Action) | ✗ | |
|
| Task (unter Action) | ◐ | AP1.5 minimal GUI |
|
||||||
| Roadmap | ◐ | 1 pro Initiative; Migration 010 |
|
| Roadmap | ◐ | 1 pro Initiative; Migration 010 |
|
||||||
| RoadmapItem | ◐ | Verify über Checkliste AP1.4b |
|
| RoadmapItem | ◐ | Verify über Checkliste AP1.4b |
|
||||||
| RoadmapItem Dependencies | ◐ | requires/blocks/related |
|
| 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({
|
export function ActionForm({
|
||||||
initial = {},
|
initial = {},
|
||||||
|
projects = [],
|
||||||
actors = [],
|
actors = [],
|
||||||
actorsLoading = false,
|
actorsLoading = false,
|
||||||
actorsError = null,
|
actorsError = null,
|
||||||
|
|
@ -31,6 +32,7 @@ export function ActionForm({
|
||||||
status: form.status.value,
|
status: form.status.value,
|
||||||
priority: form.priority.value,
|
priority: form.priority.value,
|
||||||
due_at: form.due_at.value ? new Date(form.due_at.value).toISOString() : null,
|
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,
|
assigned_actor_ids: selected,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -75,6 +77,19 @@ export function ActionForm({
|
||||||
defaultValue={isoToLocalInput(initial.due_at)}
|
defaultValue={isoToLocalInput(initial.due_at)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</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
|
<ActorSelect
|
||||||
actors={actors}
|
actors={actors}
|
||||||
defaultSelected={defaultActors}
|
defaultSelected={defaultActors}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { EmptyState } from './EmptyState.jsx'
|
||||||
export function InitiativeActionsHub({
|
export function InitiativeActionsHub({
|
||||||
initiativeId,
|
initiativeId,
|
||||||
actions,
|
actions,
|
||||||
|
projects = [],
|
||||||
actionContextById,
|
actionContextById,
|
||||||
hideDone,
|
hideDone,
|
||||||
onHideDoneChange,
|
onHideDoneChange,
|
||||||
|
|
@ -30,9 +31,10 @@ export function InitiativeActionsHub({
|
||||||
<section className="card initiative-actions-hub">
|
<section className="card initiative-actions-hub">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>Maßnahmen — operative Arbeit</h2>
|
<h2>Arbeitspakete</h2>
|
||||||
<p className="section-lead muted">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="section-actions">
|
<div className="section-actions">
|
||||||
|
|
@ -50,7 +52,7 @@ export function InitiativeActionsHub({
|
||||||
className="btn btn-primary btn-block-mobile"
|
className="btn btn-primary btn-block-mobile"
|
||||||
onClick={onToggleForm}
|
onClick={onToggleForm}
|
||||||
>
|
>
|
||||||
{showForm ? 'Abbrechen' : 'Maßnahme anlegen'}
|
{showForm ? 'Abbrechen' : 'Arbeitspaket'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -59,6 +61,7 @@ export function InitiativeActionsHub({
|
||||||
{showForm && canManage && (
|
{showForm && canManage && (
|
||||||
<div className="inline-form-block">
|
<div className="inline-form-block">
|
||||||
<ActionForm
|
<ActionForm
|
||||||
|
projects={projects}
|
||||||
actors={actors}
|
actors={actors}
|
||||||
actorsLoading={actorsLoading}
|
actorsLoading={actorsLoading}
|
||||||
actorsError={actorsError}
|
actorsError={actorsError}
|
||||||
|
|
@ -67,13 +70,13 @@ export function InitiativeActionsHub({
|
||||||
onSubmit={onCreateAction}
|
onSubmit={onCreateAction}
|
||||||
onCancel={onToggleForm}
|
onCancel={onToggleForm}
|
||||||
busy={formBusy}
|
busy={formBusy}
|
||||||
submitLabel="Maßnahme anlegen"
|
submitLabel="Arbeitspaket anlegen"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{actions.length === 0 && (
|
{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">
|
<div className="action-hub-grid">
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { NavLink, useParams } from 'react-router-dom'
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ to: '', label: 'Steuerung', end: true },
|
{ to: '', label: 'Steuerung', end: true },
|
||||||
{ to: 'plan', label: 'Plan' },
|
{ to: 'plan', label: 'Zielzustände' },
|
||||||
{ to: 'execution', label: 'Ausführung' },
|
{ to: 'execution', label: 'Ausführung' },
|
||||||
{ to: 'inbox', label: 'Eingang' },
|
{ to: 'inbox', label: 'Eingang' },
|
||||||
{ to: 'journey', label: 'Nachvollziehbarkeit' },
|
{ 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">
|
<section className="card">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>Plan</h2>
|
<h2>Zielzustände (Gates)</h2>
|
||||||
<p className="section-lead muted">
|
<p className="section-lead muted">
|
||||||
Roadmap-Elemente methodenneutral — Gates, Reifegrade oder Review-Punkte.
|
Überprüfbare Zielpunkte — optional. Operative Planung läuft unter Ausführung.
|
||||||
Erreicht nur über Verify (Evidence, Review oder Decision).
|
Erreicht nur über Verify (Kriterien, Evidence, Review).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{canManage && (
|
{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 DECISION_STATUSES = ['proposed', 'decided', 'superseded']
|
||||||
export const REVIEW_STATUSES = ['planned', 'completed', 'skipped']
|
export const REVIEW_STATUSES = ['planned', 'completed', 'skipped']
|
||||||
export const RECURRING_STATUSES = ['active', 'paused', 'ended']
|
export const RECURRING_STATUSES = ['active', 'paused', 'ended']
|
||||||
|
export const TASK_STATUSES = ['open', 'in_progress', 'done', 'discarded']
|
||||||
export const PRIORITIES = ['low', 'normal', 'high']
|
export const PRIORITIES = ['low', 'normal', 'high']
|
||||||
export const OPEN_ACTION_STATUSES = ['open', 'ready', 'in_progress', 'blocked', 'review_required']
|
export const OPEN_ACTION_STATUSES = ['open', 'ready', 'in_progress', 'blocked', 'review_required']
|
||||||
|
|
||||||
|
|
@ -114,6 +115,13 @@ export const RECURRING_STATUS_LABELS = {
|
||||||
ended: 'Beendet',
|
ended: 'Beendet',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const TASK_STATUS_LABELS = {
|
||||||
|
open: 'Offen',
|
||||||
|
in_progress: 'In Arbeit',
|
||||||
|
done: 'Erledigt',
|
||||||
|
discarded: 'Verworfen',
|
||||||
|
}
|
||||||
|
|
||||||
export const PRIORITY_LABELS = {
|
export const PRIORITY_LABELS = {
|
||||||
low: 'Niedrig',
|
low: 'Niedrig',
|
||||||
normal: 'Normal',
|
normal: 'Normal',
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,11 @@ import {
|
||||||
deleteRoadmapItem,
|
deleteRoadmapItem,
|
||||||
verifyRoadmapItemReached,
|
verifyRoadmapItemReached,
|
||||||
} from '../api/roadmap.js'
|
} from '../api/roadmap.js'
|
||||||
|
import {
|
||||||
|
listInitiativeProjects,
|
||||||
|
createInitiativeProject,
|
||||||
|
deleteProject,
|
||||||
|
} from '../api/projects.js'
|
||||||
import {
|
import {
|
||||||
listInitiativeEvidence,
|
listInitiativeEvidence,
|
||||||
createInitiativeEvidence,
|
createInitiativeEvidence,
|
||||||
|
|
@ -76,6 +81,8 @@ export function InitiativeOperationsProvider({ children }) {
|
||||||
const [blockers, setBlockers] = useState([])
|
const [blockers, setBlockers] = useState([])
|
||||||
const [backlogItems, setBacklogItems] = useState([])
|
const [backlogItems, setBacklogItems] = useState([])
|
||||||
const [roadmapItems, setRoadmapItems] = useState([])
|
const [roadmapItems, setRoadmapItems] = useState([])
|
||||||
|
const [projects, setProjects] = useState([])
|
||||||
|
const [selectedProjectId, setSelectedProjectId] = useState('')
|
||||||
const [evidenceItems, setEvidenceItems] = useState([])
|
const [evidenceItems, setEvidenceItems] = useState([])
|
||||||
const [decisions, setDecisions] = useState([])
|
const [decisions, setDecisions] = useState([])
|
||||||
const [reviews, setReviews] = useState([])
|
const [reviews, setReviews] = useState([])
|
||||||
|
|
@ -112,6 +119,7 @@ export function InitiativeOperationsProvider({ children }) {
|
||||||
loads.push(listInitiativeBlockers(id).then(setBlockers).catch(() => setBlockers([])))
|
loads.push(listInitiativeBlockers(id).then(setBlockers).catch(() => setBlockers([])))
|
||||||
loads.push(listInitiativeBacklog(id).then(setBacklogItems).catch(() => setBacklogItems([])))
|
loads.push(listInitiativeBacklog(id).then(setBacklogItems).catch(() => setBacklogItems([])))
|
||||||
loads.push(listInitiativeRoadmapItems(id).then(setRoadmapItems).catch(() => setRoadmapItems([])))
|
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(listInitiativeEvidence(id).then(setEvidenceItems).catch(() => setEvidenceItems([])))
|
||||||
loads.push(listInitiativeDecisions(id).then(setDecisions).catch(() => setDecisions([])))
|
loads.push(listInitiativeDecisions(id).then(setDecisions).catch(() => setDecisions([])))
|
||||||
loads.push(listInitiativeReviews(id).then(setReviews).catch(() => setReviews([])))
|
loads.push(listInitiativeReviews(id).then(setReviews).catch(() => setReviews([])))
|
||||||
|
|
@ -147,11 +155,16 @@ export function InitiativeOperationsProvider({ children }) {
|
||||||
}, [load])
|
}, [load])
|
||||||
|
|
||||||
const visibleActions = useMemo(
|
const visibleActions = useMemo(
|
||||||
() =>
|
() => {
|
||||||
hideDone
|
let list = hideDone
|
||||||
? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded')
|
? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded')
|
||||||
: actions,
|
: actions
|
||||||
[actions, hideDone]
|
if (selectedProjectId) {
|
||||||
|
list = list.filter((a) => a.project_id === selectedProjectId)
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
},
|
||||||
|
[actions, hideDone, selectedProjectId]
|
||||||
)
|
)
|
||||||
|
|
||||||
const actionContextById = useMemo(
|
const actionContextById = useMemo(
|
||||||
|
|
@ -185,7 +198,11 @@ export function InitiativeOperationsProvider({ children }) {
|
||||||
: context?.actor?.id
|
: context?.actor?.id
|
||||||
? [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)
|
setShowActionForm(false)
|
||||||
await load()
|
await load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -205,6 +222,8 @@ export function InitiativeOperationsProvider({ children }) {
|
||||||
priority: payload.priority,
|
priority: payload.priority,
|
||||||
due_at: payload.due_at,
|
due_at: payload.due_at,
|
||||||
clear_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')) {
|
if (capabilities.has('kairo.action.manage')) {
|
||||||
await setActionAssignments(actionId, payload.assigned_actor_ids || [])
|
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(
|
const milestones = useMemo(
|
||||||
() => roadmapItems.filter((item) => item.item_type === 'milestone'),
|
() => roadmapItems.filter((item) => item.item_type === 'milestone'),
|
||||||
[roadmapItems]
|
[roadmapItems]
|
||||||
|
|
@ -520,6 +561,9 @@ export function InitiativeOperationsProvider({ children }) {
|
||||||
unlinkedBlockers,
|
unlinkedBlockers,
|
||||||
backlogItems,
|
backlogItems,
|
||||||
roadmapItems,
|
roadmapItems,
|
||||||
|
projects,
|
||||||
|
selectedProjectId,
|
||||||
|
setSelectedProjectId,
|
||||||
milestones,
|
milestones,
|
||||||
evidenceItems,
|
evidenceItems,
|
||||||
decisions,
|
decisions,
|
||||||
|
|
@ -567,7 +611,9 @@ export function InitiativeOperationsProvider({ children }) {
|
||||||
handleDeleteRoadmapItem,
|
handleDeleteRoadmapItem,
|
||||||
handleCreateMilestone: handleCreateRoadmapItem,
|
handleCreateMilestone: handleCreateRoadmapItem,
|
||||||
handleMilestoneStatus: handleRoadmapItemStatus,
|
handleMilestoneStatus: handleRoadmapItemStatus,
|
||||||
handleDeleteMilestone: handleDeleteRoadmapItem,
|
handleDeleteMilestone: handleDeleteRoadmapItem,
|
||||||
|
handleCreateProject,
|
||||||
|
handleDeleteProject,
|
||||||
handleCreateEvidence,
|
handleCreateEvidence,
|
||||||
handleEvidenceStatus,
|
handleEvidenceStatus,
|
||||||
handleDeleteEvidence,
|
handleDeleteEvidence,
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,12 @@ import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
import { getAction } from '../api/actions.js'
|
import { getAction } from '../api/actions.js'
|
||||||
import { getInitiativeSteeringSnapshot } from '../api/initiatives.js'
|
import { getInitiativeSteeringSnapshot } from '../api/initiatives.js'
|
||||||
|
import { listInitiativeProjects } from '../api/projects.js'
|
||||||
import { updateAction, setActionAssignments } from '../api/actions.js'
|
import { updateAction, setActionAssignments } from '../api/actions.js'
|
||||||
import { createInitiativeBlocker } from '../api/blockers.js'
|
import { createInitiativeBlocker } from '../api/blockers.js'
|
||||||
import { ActionHubCard } from '../components/ActionHubCard.jsx'
|
import { ActionHubCard } from '../components/ActionHubCard.jsx'
|
||||||
import { ActionForm } from '../components/ActionForm.jsx'
|
import { ActionForm } from '../components/ActionForm.jsx'
|
||||||
|
import { TasksSection } from '../components/TasksSection.jsx'
|
||||||
import { ErrorState } from '../components/ErrorState.jsx'
|
import { ErrorState } from '../components/ErrorState.jsx'
|
||||||
import { LoadingState } from '../components/LoadingState.jsx'
|
import { LoadingState } from '../components/LoadingState.jsx'
|
||||||
import { useInitiativeOperations } from '../context/InitiativeOperationsContext.jsx'
|
import { useInitiativeOperations } from '../context/InitiativeOperationsContext.jsx'
|
||||||
|
|
@ -24,11 +26,13 @@ function ActionDetailBody({
|
||||||
formBusy,
|
formBusy,
|
||||||
capabilities,
|
capabilities,
|
||||||
actorsState,
|
actorsState,
|
||||||
|
projects = [],
|
||||||
}) {
|
}) {
|
||||||
if (editing) {
|
if (editing) {
|
||||||
return (
|
return (
|
||||||
<ActionForm
|
<ActionForm
|
||||||
initial={action}
|
initial={action}
|
||||||
|
projects={projects}
|
||||||
actors={actorsState.actors}
|
actors={actorsState.actors}
|
||||||
actorsLoading={actorsState.loading}
|
actorsLoading={actorsState.loading}
|
||||||
actorsError={actorsState.error}
|
actorsError={actorsState.error}
|
||||||
|
|
@ -43,25 +47,31 @@ function ActionDetailBody({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionHubCard
|
<>
|
||||||
action={action}
|
<ActionHubCard
|
||||||
context={context}
|
action={action}
|
||||||
editing={false}
|
context={context}
|
||||||
onEdit={onEdit}
|
editing={false}
|
||||||
onCancelEdit={onCancelEdit}
|
onEdit={onEdit}
|
||||||
onSubmitEdit={onSubmit}
|
onCancelEdit={onCancelEdit}
|
||||||
onQuickStatus={onQuickStatus}
|
onSubmitEdit={onSubmit}
|
||||||
onCreateBlocker={onCreateBlocker}
|
onQuickStatus={onQuickStatus}
|
||||||
canManage={capabilities.has('kairo.action.manage')}
|
onCreateBlocker={onCreateBlocker}
|
||||||
canManageBlocker={capabilities.has('kairo.blocker.manage')}
|
canManage={capabilities.has('kairo.action.manage')}
|
||||||
formBusy={formBusy}
|
canManageBlocker={capabilities.has('kairo.blocker.manage')}
|
||||||
actors={actorsState.actors}
|
formBusy={formBusy}
|
||||||
actorsLoading={actorsState.loading}
|
actors={actorsState.actors}
|
||||||
actorsError={actorsState.error}
|
actorsLoading={actorsState.loading}
|
||||||
actorsUsedFallback={actorsState.usedFallback}
|
actorsError={actorsState.error}
|
||||||
onReloadActors={actorsState.reload}
|
actorsUsedFallback={actorsState.usedFallback}
|
||||||
detailMode
|
onReloadActors={actorsState.reload}
|
||||||
/>
|
detailMode
|
||||||
|
/>
|
||||||
|
<TasksSection
|
||||||
|
actionId={action.id}
|
||||||
|
canManage={capabilities.has('kairo.action.manage')}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,6 +80,7 @@ function ActionDetailStandalone() {
|
||||||
const { capabilities } = useCapabilities()
|
const { capabilities } = useCapabilities()
|
||||||
const actorsState = useActors()
|
const actorsState = useActors()
|
||||||
const [action, setAction] = useState(null)
|
const [action, setAction] = useState(null)
|
||||||
|
const [projects, setProjects] = useState([])
|
||||||
const [context, setContext] = useState(null)
|
const [context, setContext] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
|
@ -82,7 +93,11 @@ function ActionDetailStandalone() {
|
||||||
try {
|
try {
|
||||||
const actionData = await getAction(actionId)
|
const actionData = await getAction(actionId)
|
||||||
setAction(actionData)
|
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)
|
setContext((snap.actions || []).find((a) => a.id === actionId) || null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
|
|
@ -188,6 +203,7 @@ function ActionDetailStandalone() {
|
||||||
formBusy={formBusy}
|
formBusy={formBusy}
|
||||||
capabilities={capabilities}
|
capabilities={capabilities}
|
||||||
actorsState={actorsState}
|
actorsState={actorsState}
|
||||||
|
projects={projects}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -228,6 +244,7 @@ function ActionDetailNested() {
|
||||||
usedFallback: ops.actorsUsedFallback,
|
usedFallback: ops.actorsUsedFallback,
|
||||||
reload: ops.reloadActors,
|
reload: ops.reloadActors,
|
||||||
}}
|
}}
|
||||||
|
projects={ops.projects}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
||||||
import { InitiativeActionsHub } from '../../components/InitiativeActionsHub.jsx'
|
import { InitiativeActionsHub } from '../../components/InitiativeActionsHub.jsx'
|
||||||
|
import { ProjectsSection } from '../../components/ProjectsSection.jsx'
|
||||||
import { BlockersSection } from '../../components/BlockersSection.jsx'
|
import { BlockersSection } from '../../components/BlockersSection.jsx'
|
||||||
|
|
||||||
export function InitiativeExecutionPage() {
|
export function InitiativeExecutionPage() {
|
||||||
const ops = useInitiativeOperations()
|
const ops = useInitiativeOperations()
|
||||||
const {
|
const {
|
||||||
visibleActions,
|
visibleActions,
|
||||||
|
projects,
|
||||||
|
selectedProjectId,
|
||||||
|
setSelectedProjectId,
|
||||||
actionContextById,
|
actionContextById,
|
||||||
hideDone,
|
hideDone,
|
||||||
setHideDone,
|
setHideDone,
|
||||||
|
|
@ -24,9 +28,20 @@ export function InitiativeExecutionPage() {
|
||||||
<>
|
<>
|
||||||
{error && <p className="error">{error}</p>}
|
{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
|
<InitiativeActionsHub
|
||||||
initiativeId={initiativeId}
|
initiativeId={initiativeId}
|
||||||
actions={visibleActions}
|
actions={visibleActions}
|
||||||
|
projects={projects}
|
||||||
actionContextById={actionContextById}
|
actionContextById={actionContextById}
|
||||||
hideDone={hideDone}
|
hideDone={hideDone}
|
||||||
onHideDoneChange={setHideDone}
|
onHideDoneChange={setHideDone}
|
||||||
|
|
@ -56,7 +71,7 @@ export function InitiativeExecutionPage() {
|
||||||
<BlockersSection
|
<BlockersSection
|
||||||
blockers={unlinkedBlockers}
|
blockers={unlinkedBlockers}
|
||||||
heading="Vorhaben-Blocker"
|
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."
|
emptyMessage="Keine vorhabenweiten Blocker."
|
||||||
canManage={capabilities.has('kairo.blocker.manage')}
|
canManage={capabilities.has('kairo.blocker.manage')}
|
||||||
onCreate={ops.handleCreateBlocker}
|
onCreate={ops.handleCreateBlocker}
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,7 @@ export function RoadmapItemDetailPage() {
|
||||||
return (
|
return (
|
||||||
<section className="card roadmap-item-detail">
|
<section className="card roadmap-item-detail">
|
||||||
<p className="breadcrumb muted">
|
<p className="breadcrumb muted">
|
||||||
<Link to={`/initiatives/${initiativeId}/plan`}>Plan</Link>
|
<Link to={`/initiatives/${initiativeId}/plan`}>Zielzustände</Link>
|
||||||
{' · '}
|
{' · '}
|
||||||
{item.title}
|
{item.title}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user