AP1.5d: Rekursive Tasks im Plan-Knoten Arbeit.
Some checks failed
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Failing after 1m57s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped

parent_task_id mit Validierung und Roll-up; aufklappbarer Task-Baum unter Arbeitspaketen im Plan-Modus (max. 3 UI-Ebenen).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-10 06:18:55 +02:00
parent a6fc86025c
commit ba28a86fe5
13 changed files with 909 additions and 54 deletions

View File

@ -0,0 +1,12 @@
-- AP1.5d: Rekursive Tasks unter Action
ALTER TABLE tasks
ADD COLUMN IF NOT EXISTS parent_task_id UUID NULL
REFERENCES tasks (id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS idx_tasks_parent
ON tasks (tenant_id, parent_task_id)
WHERE parent_task_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_tasks_action_parent
ON tasks (tenant_id, action_id, parent_task_id);

View File

@ -35,6 +35,7 @@ class TaskCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255) title: str = Field(min_length=1, max_length=255)
description: str = "" description: str = ""
status: Literal["open", "in_progress", "done", "discarded"] = "open" status: Literal["open", "in_progress", "done", "discarded"] = "open"
parent_task_id: Optional[str] = None
roadmap_item_id: Optional[str] = None roadmap_item_id: Optional[str] = None
sort_order: int = 0 sort_order: int = 0
due_at: Optional[str] = None due_at: Optional[str] = None
@ -87,6 +88,7 @@ def create_action_task(
title=body.title, title=body.title,
description=body.description, description=body.description,
status=body.status, status=body.status,
parent_task_id=body.parent_task_id,
roadmap_item_id=body.roadmap_item_id, roadmap_item_id=body.roadmap_item_id,
sort_order=body.sort_order, sort_order=body.sort_order,
due_at=due_at, due_at=due_at,

View File

@ -18,6 +18,8 @@ class TaskUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255) title: Optional[str] = Field(default=None, min_length=1, max_length=255)
description: Optional[str] = None description: Optional[str] = None
status: Optional[Literal["open", "in_progress", "done", "discarded"]] = None status: Optional[Literal["open", "in_progress", "done", "discarded"]] = None
parent_task_id: Optional[str] = None
clear_parent_task: bool = False
roadmap_item_id: Optional[str] = None roadmap_item_id: Optional[str] = None
clear_roadmap_item: bool = False clear_roadmap_item: bool = False
sort_order: Optional[int] = None sort_order: Optional[int] = None
@ -45,6 +47,8 @@ def update_task(
title=body.title, title=body.title,
description=body.description, description=body.description,
status=body.status, status=body.status,
parent_task_id=body.parent_task_id,
clear_parent_task=body.clear_parent_task,
roadmap_item_id=body.roadmap_item_id, roadmap_item_id=body.roadmap_item_id,
clear_roadmap_item=body.clear_roadmap_item, clear_roadmap_item=body.clear_roadmap_item,
sort_order=body.sort_order, sort_order=body.sort_order,
@ -63,7 +67,11 @@ def delete_task(
task_id: str, task_id: str,
ctx: TenantContext = Depends(require_capability("kairo.action.manage")), ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
): ):
if not task_service.delete_task( try:
deleted = task_service.delete_task(
tenant_id=ctx.tenant_id, task_id=task_id, user_id=ctx.user_id tenant_id=ctx.tenant_id, task_id=task_id, user_id=ctx.user_id
): )
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not deleted:
raise HTTPException(status_code=404, detail="Task nicht gefunden") raise HTTPException(status_code=404, detail="Task nicht gefunden")

View File

@ -1,4 +1,4 @@
"""Task service — kleinste Einheit unter Action (AP1.5).""" """Task service — kleinste Einheit unter Action (AP1.5, AP1.5d rekursiv)."""
from __future__ import annotations from __future__ import annotations
@ -13,16 +13,18 @@ from services.audit import log_audit
TaskStatus = Literal["open", "in_progress", "done", "discarded"] TaskStatus = Literal["open", "in_progress", "done", "discarded"]
TASK_STATUSES = frozenset({"open", "in_progress", "done", "discarded"}) TASK_STATUSES = frozenset({"open", "in_progress", "done", "discarded"})
TERMINAL_TASK_STATUSES = frozenset({"done", "discarded"})
MAX_TASK_DEPTH = 10
_TASK_COLUMNS = """ _TASK_COLUMNS = """
id, tenant_id, action_id, title, description, status, id, tenant_id, action_id, parent_task_id, title, description, status,
roadmap_item_id, sort_order, due_at, created_at, updated_at roadmap_item_id, sort_order, 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", "action_id", "roadmap_item_id"): for key in ("id", "tenant_id", "action_id", "parent_task_id", "roadmap_item_id"):
if result.get(key): if result.get(key):
result[key] = str(result[key]) result[key] = str(result[key])
if result.get("due_at"): if result.get("due_at"):
@ -62,6 +64,142 @@ def _validate_roadmap_item_for_action(
raise ValueError("Gate gehört nicht zum Vorhaben des Arbeitspakets") raise ValueError("Gate gehört nicht zum Vorhaben des Arbeitspakets")
def _load_action_task_maps(
cur, *, tenant_id: str, action_id: str
) -> tuple[dict[str, dict[str, Any]], dict[str, list[str]]]:
cur.execute(
f"""
SELECT {_TASK_COLUMNS}
FROM tasks
WHERE tenant_id = %s AND action_id = %s
""",
(tenant_id, action_id),
)
rows = [dict(r) for r in cur.fetchall()]
by_id = {str(r["id"]): r for r in rows}
by_parent: dict[str, list[str]] = {}
for row in rows:
parent_id = row.get("parent_task_id")
if parent_id:
by_parent.setdefault(str(parent_id), []).append(str(row["id"]))
return by_id, by_parent
def _collect_descendant_ids(task_id: str, by_parent: dict[str, list[str]]) -> set[str]:
result: set[str] = set()
stack = list(by_parent.get(task_id, []))
while stack:
child_id = stack.pop()
if child_id in result:
continue
result.add(child_id)
stack.extend(by_parent.get(child_id, []))
return result
def _task_depth_from_map(
task_id: str,
by_id: dict[str, dict[str, Any]],
memo: dict[str, int],
) -> int:
if task_id in memo:
return memo[task_id]
row = by_id.get(task_id)
if not row or not row.get("parent_task_id"):
memo[task_id] = 1
return 1
parent_id = str(row["parent_task_id"])
depth = 1 + _task_depth_from_map(parent_id, by_id, memo)
memo[task_id] = depth
return depth
def _subtree_height(task_id: str, by_parent: dict[str, list[str]]) -> int:
children = by_parent.get(task_id, [])
if not children:
return 1
return 1 + max(_subtree_height(child_id, by_parent) for child_id in children)
def _validate_parent_task(
cur,
*,
tenant_id: str,
action_id: str,
task_id: Optional[str],
parent_task_id: Optional[str],
) -> None:
if not parent_task_id:
return
if task_id and parent_task_id == task_id:
raise ValueError("Task kann nicht sein eigener Parent sein")
cur.execute(
f"""
SELECT {_TASK_COLUMNS}
FROM tasks
WHERE id = %s AND tenant_id = %s AND action_id = %s
""",
(parent_task_id, tenant_id, action_id),
)
parent = cur.fetchone()
if not parent:
raise ValueError("Parent-Task gehört nicht zu diesem Arbeitspaket")
by_id, by_parent = _load_action_task_maps(cur, tenant_id=tenant_id, action_id=action_id)
if task_id:
descendants = _collect_descendant_ids(task_id, by_parent)
if parent_task_id in descendants:
raise ValueError("Zyklus: Parent darf kein Nachfahr sein")
memo: dict[str, int] = {}
parent_depth = _task_depth_from_map(parent_task_id, by_id, memo)
subtree_height = _subtree_height(task_id, by_parent) if task_id else 1
if parent_depth + subtree_height > MAX_TASK_DEPTH:
raise ValueError(f"Maximale Task-Tiefe ({MAX_TASK_DEPTH}) würde überschritten")
def _has_open_children(cur, *, tenant_id: str, task_id: str) -> bool:
cur.execute(
"""
SELECT 1 FROM tasks
WHERE tenant_id = %s AND parent_task_id = %s
AND status NOT IN ('done', 'discarded')
LIMIT 1
""",
(tenant_id, task_id),
)
return cur.fetchone() is not None
def _rollup_parent_status(cur, *, tenant_id: str, parent_task_id: str) -> None:
cur.execute(
"""
SELECT status FROM tasks
WHERE tenant_id = %s AND parent_task_id = %s
""",
(tenant_id, parent_task_id),
)
child_statuses = [row[0] for row in cur.fetchall()]
if not child_statuses:
return
if any(status not in TERMINAL_TASK_STATUSES for status in child_statuses):
return
cur.execute(
"""
UPDATE tasks
SET status = 'done', updated_at = NOW()
WHERE id = %s AND tenant_id = %s AND status NOT IN ('done', 'discarded')
RETURNING parent_task_id
""",
(parent_task_id, tenant_id),
)
row = cur.fetchone()
if row and row[0]:
_rollup_parent_status(cur, tenant_id=tenant_id, parent_task_id=str(row[0]))
def create_task( def create_task(
*, *,
tenant_id: str, tenant_id: str,
@ -69,6 +207,7 @@ def create_task(
title: str, title: str,
description: str = "", description: str = "",
status: TaskStatus = "open", status: TaskStatus = "open",
parent_task_id: Optional[str] = None,
roadmap_item_id: Optional[str] = None, roadmap_item_id: Optional[str] = None,
sort_order: int = 0, sort_order: int = 0,
due_at: Optional[datetime] = None, due_at: Optional[datetime] = None,
@ -83,6 +222,13 @@ def create_task(
conn = get_connection() conn = get_connection()
try: try:
with conn.cursor(cursor_factory=RealDictCursor) as cur: with conn.cursor(cursor_factory=RealDictCursor) as cur:
_validate_parent_task(
cur,
tenant_id=tenant_id,
action_id=action_id,
task_id=None,
parent_task_id=parent_task_id,
)
_validate_roadmap_item_for_action( _validate_roadmap_item_for_action(
cur, cur,
tenant_id=tenant_id, tenant_id=tenant_id,
@ -92,15 +238,16 @@ def create_task(
cur.execute( cur.execute(
f""" f"""
INSERT INTO tasks ( INSERT INTO tasks (
tenant_id, action_id, title, description, status, tenant_id, action_id, parent_task_id, title, description, status,
roadmap_item_id, sort_order, due_at roadmap_item_id, sort_order, due_at
) )
VALUES (%s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING {_TASK_COLUMNS} RETURNING {_TASK_COLUMNS}
""", """,
( (
tenant_id, tenant_id,
action_id, action_id,
parent_task_id,
title, title,
description, description,
status, status,
@ -118,7 +265,12 @@ def create_task(
"task.created", "task.created",
user_id=user_id, user_id=user_id,
tenant_id=tenant_id, tenant_id=tenant_id,
details={"task_id": row["id"], "action_id": action_id, "title": title}, details={
"task_id": row["id"],
"action_id": action_id,
"title": title,
"parent_task_id": parent_task_id,
},
) )
return row return row
@ -164,6 +316,8 @@ def update_task(
title: Optional[str] = None, title: Optional[str] = None,
description: Optional[str] = None, description: Optional[str] = None,
status: Optional[TaskStatus] = None, status: Optional[TaskStatus] = None,
parent_task_id: Optional[str] = None,
clear_parent_task: bool = False,
roadmap_item_id: Optional[str] = None, roadmap_item_id: Optional[str] = None,
clear_roadmap_item: bool = False, clear_roadmap_item: bool = False,
sort_order: Optional[int] = None, sort_order: Optional[int] = None,
@ -189,22 +343,14 @@ def update_task(
params.append(description) params.append(description)
if status is not None: if status is not None:
_validate_status(status) _validate_status(status)
updates.append("status = %s") if clear_parent_task:
params.append(status) updates.append("parent_task_id = NULL")
elif parent_task_id is not None:
updates.append("parent_task_id = %s")
params.append(parent_task_id)
if clear_roadmap_item: if clear_roadmap_item:
updates.append("roadmap_item_id = NULL") updates.append("roadmap_item_id = NULL")
elif roadmap_item_id is not None: elif roadmap_item_id is not None:
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") updates.append("roadmap_item_id = %s")
params.append(roadmap_item_id) params.append(roadmap_item_id)
if sort_order is not None: if sort_order is not None:
@ -216,15 +362,39 @@ def update_task(
updates.append("due_at = %s") updates.append("due_at = %s")
params.append(due_at) params.append(due_at)
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
if parent_task_id is not None and not clear_parent_task:
_validate_parent_task(
cur,
tenant_id=tenant_id,
action_id=existing["action_id"],
task_id=task_id,
parent_task_id=parent_task_id,
)
if roadmap_item_id is not None and not clear_roadmap_item:
_validate_roadmap_item_for_action(
cur,
tenant_id=tenant_id,
initiative_id=action["initiative_id"],
roadmap_item_id=roadmap_item_id,
)
if status is not None:
if status == "done" and _has_open_children(
cur, tenant_id=tenant_id, task_id=task_id
):
raise ValueError(
"Task kann nicht abgeschlossen werden — offene Unteraufgaben vorhanden"
)
updates.append("status = %s")
params.append(status)
if not updates: if not updates:
return existing return existing
updates.append("updated_at = NOW()") updates.append("updated_at = NOW()")
params.extend([task_id, tenant_id]) params.extend([task_id, tenant_id])
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute( cur.execute(
f""" f"""
UPDATE tasks SET {", ".join(updates)} UPDATE tasks SET {", ".join(updates)}
@ -237,6 +407,13 @@ def update_task(
if not row: if not row:
return None return None
result = _serialize_row(dict(row)) result = _serialize_row(dict(row))
if status == "done" and result.get("parent_task_id"):
_rollup_parent_status(
cur,
tenant_id=tenant_id,
parent_task_id=str(result["parent_task_id"]),
)
conn.commit() conn.commit()
finally: finally:
conn.close() conn.close()
@ -247,13 +424,25 @@ def update_task(
tenant_id=tenant_id, tenant_id=tenant_id,
details={"task_id": task_id}, details={"task_id": task_id},
) )
return result return get_task(tenant_id=tenant_id, task_id=task_id)
def delete_task(*, tenant_id: str, task_id: str, user_id: Optional[str] = None) -> bool: def delete_task(*, tenant_id: str, task_id: str, user_id: Optional[str] = None) -> bool:
conn = get_connection() conn = get_connection()
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(
"""
SELECT 1 FROM tasks
WHERE parent_task_id = %s AND tenant_id = %s
LIMIT 1
""",
(task_id, tenant_id),
)
if cur.fetchone():
raise ValueError(
"Task hat Unteraufgaben — zuerst löschen oder verschieben"
)
cur.execute( cur.execute(
"DELETE FROM tasks WHERE id = %s AND tenant_id = %s RETURNING id", "DELETE FROM tasks WHERE id = %s AND tenant_id = %s RETURNING id",
(task_id, tenant_id), (task_id, tenant_id),

View File

@ -0,0 +1,108 @@
"""Rekursive Tasks (AP1.5d)."""
from __future__ import annotations
from tests.factories import provision_user_in_tenant
from tests.test_initiatives_actions import _auth, _create_initiative, _login
def _create_action(client, token, initiative_id, title="AP Alpha"):
res = client.post(
f"/api/initiatives/{initiative_id}/actions",
json={"title": title},
headers=_auth(token),
)
assert res.status_code == 201
return res.json()
def _create_task(client, token, action_id, **payload):
return client.post(
f"/api/actions/{action_id}/tasks",
json=payload,
headers=_auth(token),
)
def test_nested_tasks_and_rollup(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative = _create_initiative(client, token, title="Task Tree").json()
action = _create_action(client, token, initiative["id"])
parent = _create_task(client, token, action["id"], title="Parent").json()
child = _create_task(
client,
token,
action["id"],
title="Child",
parent_task_id=parent["id"],
)
assert child.status_code == 201
child_id = child.json()["id"]
blocked = client.patch(
f"/api/tasks/{parent['id']}",
json={"status": "done"},
headers=_auth(token),
)
assert blocked.status_code == 400
done_child = client.patch(
f"/api/tasks/{child_id}",
json={"status": "done"},
headers=_auth(token),
)
assert done_child.status_code == 200
parent_after = client.get(
f"/api/actions/{action['id']}/tasks",
headers=_auth(token),
).json()
parent_row = next(item for item in parent_after if item["id"] == parent["id"])
assert parent_row["status"] == "done"
def test_task_cycle_rejected(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative = _create_initiative(client, token, title="Cycle").json()
action = _create_action(client, token, initiative["id"])
a = _create_task(client, token, action["id"], title="A").json()
b = _create_task(
client,
token,
action["id"],
title="B",
parent_task_id=a["id"],
).json()
res = client.patch(
f"/api/tasks/{a['id']}",
json={"parent_task_id": b["id"]},
headers=_auth(token),
)
assert res.status_code == 400
def test_delete_task_with_children_rejected(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative = _create_initiative(client, token, title="Delete Guard").json()
action = _create_action(client, token, initiative["id"])
parent = _create_task(client, token, action["id"], title="Parent").json()
_create_task(
client,
token,
action["id"],
title="Child",
parent_task_id=parent["id"],
)
res = client.delete(
f"/api/tasks/{parent['id']}",
headers=_auth(token),
)
assert res.status_code == 400

View File

@ -1,3 +1,3 @@
APP_VERSION = "0.17.0-ap1.10c" APP_VERSION = "0.17.0-ap1.5d"
DB_SCHEMA_VERSION = "017" DB_SCHEMA_VERSION = "018"
APP_NAME = "jinkendo-kairo" APP_NAME = "jinkendo-kairo"

View File

@ -1,6 +1,6 @@
{ {
"name": "kairo-jinkendo-frontend", "name": "kairo-jinkendo-frontend",
"version": "0.17.0-ap1.10c", "version": "0.17.0-ap1.5d",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@ -0,0 +1,194 @@
import { useEffect, useMemo, useState } from 'react'
import {
createActionTask,
deleteTask,
listActionTasks,
updateTask,
} from '../api/tasks.js'
import { buildTasksByParent } from '../utils/taskTree.js'
import { Modal } from './Modal.jsx'
import { TaskForm, TaskTreeNodes } from './TaskForm.jsx'
export function ActionTaskPanel({
actionId,
roadmapItems,
canManage,
expanded,
onToggle,
openTaskCount,
}) {
const [tasks, setTasks] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const [modal, setModal] = useState(null)
const [reloadKey, setReloadKey] = useState(0)
useEffect(() => {
if (!expanded || !actionId) return undefined
let cancelled = false
setLoading(true)
setError(null)
listActionTasks(actionId)
.then((data) => {
if (!cancelled) setTasks(data)
})
.catch((err) => {
if (!cancelled) setError(err.message)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [expanded, actionId, reloadKey])
const tasksByParent = useMemo(() => buildTasksByParent(tasks), [tasks])
async function reload() {
setReloadKey((value) => value + 1)
}
async function handleCreate(payload) {
setBusy(true)
setError(null)
try {
await createActionTask(actionId, payload)
setModal(null)
await reload()
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
async function handleUpdate(taskId, payload) {
setBusy(true)
setError(null)
try {
await updateTask(taskId, payload)
setModal(null)
await reload()
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
async function handleStatus(taskId, status) {
setBusy(true)
setError(null)
try {
await updateTask(taskId, { status })
await reload()
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
async function handleDelete(taskId) {
setBusy(true)
setError(null)
try {
await deleteTask(taskId)
await reload()
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
const taskLabel = openTaskCount != null ? ` (${openTaskCount} offen)` : ''
return (
<div className="action-task-panel">
<button
type="button"
className="action-task-panel__toggle"
onClick={onToggle}
aria-expanded={expanded}
>
{expanded ? '▾' : '▸'} Aufgaben{taskLabel}
</button>
{expanded && (
<div className="action-task-panel__body">
{error && <p className="error">{error}</p>}
{loading && <p className="muted">Aufgaben werden geladen </p>}
{!loading && tasks.length === 0 && (
<p className="muted">Noch keine Aufgaben für dieses Arbeitspaket.</p>
)}
{!loading && tasks.length > 0 && (
<TaskTreeNodes
tasksByParent={tasksByParent}
roadmapItems={roadmapItems}
canManage={canManage}
busy={busy}
onEdit={(task) => setModal({ mode: 'edit', task, key: task.id })}
onAddSubtask={(task) =>
setModal({
mode: 'create',
parentTaskId: task.id,
parentLabel: task.title,
key: `sub-${task.id}`,
})
}
onStatusChange={handleStatus}
onDelete={handleDelete}
/>
)}
{canManage && (
<button
type="button"
className="btn btn-secondary btn-sm action-task-panel__add"
disabled={busy}
onClick={() => setModal({ mode: 'create', key: 'root' })}
>
Aufgabe hinzufügen
</button>
)}
</div>
)}
<Modal
open={Boolean(modal)}
title={
modal?.mode === 'edit'
? 'Aufgabe bearbeiten'
: modal?.parentLabel
? 'Unteraufgabe anlegen'
: 'Aufgabe anlegen'
}
onClose={() => setModal(null)}
>
{modal?.mode === 'edit' ? (
<TaskForm
initial={modal.task}
roadmapItems={roadmapItems}
onSubmit={(payload) => handleUpdate(modal.task.id, payload)}
onCancel={() => setModal(null)}
busy={busy}
/>
) : (
modal && (
<TaskForm
parentTaskId={modal.parentTaskId}
parentLabel={modal.parentLabel}
roadmapItems={roadmapItems}
onSubmit={handleCreate}
onCancel={() => setModal(null)}
busy={busy}
submitLabel="Anlegen"
/>
)
)}
</Modal>
</div>
)
}

View File

@ -5,6 +5,7 @@ import { StatusBadge } from './StatusBadge.jsx'
import { PriorityBadge } from './PriorityBadge.jsx' import { PriorityBadge } from './PriorityBadge.jsx'
import { Modal } from './Modal.jsx' import { Modal } from './Modal.jsx'
import { ActionForm } from './ActionForm.jsx' import { ActionForm } from './ActionForm.jsx'
import { ActionTaskPanel } from './ActionTaskPanel.jsx'
import { gateTitleById } from './GateSelect.jsx' import { gateTitleById } from './GateSelect.jsx'
import { buildProjectPath, collectProjectSubtreeIds } from '../utils/projectTree.js' import { buildProjectPath, collectProjectSubtreeIds } from '../utils/projectTree.js'
import { actionPath } from '../utils/routes.js' import { actionPath } from '../utils/routes.js'
@ -32,6 +33,7 @@ export function PlanActionsSection({
busy, busy,
}) { }) {
const [showCreate, setShowCreate] = useState(false) const [showCreate, setShowCreate] = useState(false)
const [expandedActions, setExpandedActions] = useState(() => new Set())
const visibleActions = useMemo(() => { const visibleActions = useMemo(() => {
let list = hideDone let list = hideDone
@ -60,14 +62,22 @@ export function PlanActionsSection({
setShowCreate(false) setShowCreate(false)
} }
function toggleActionTasks(actionId) {
setExpandedActions((prev) => {
const next = new Set(prev)
if (next.has(actionId)) next.delete(actionId)
else next.add(actionId)
return next
})
}
return ( return (
<section className="card plan-actions-section"> <section className="card plan-actions-section">
<div className="section-header"> <div className="section-header">
<div> <div>
<h2>Arbeit</h2> <h2>Arbeit</h2>
<p className="section-lead muted"> <p className="section-lead muted">
Committete Arbeitspakete im Plan-Kontext Detail und Ausführung über die Committete Arbeitspakete und zugehörige Aufgaben Ausführung über die Objektseite.
Objektseite.
</p> </p>
</div> </div>
<div className="section-actions"> <div className="section-actions">
@ -104,6 +114,7 @@ export function PlanActionsSection({
<ul className="item-list plan-actions-list"> <ul className="item-list plan-actions-list">
{visibleActions.map((action) => ( {visibleActions.map((action) => (
<li key={action.id} className="list-item card-list-item plan-actions-list__item"> <li key={action.id} className="list-item card-list-item plan-actions-list__item">
<div className="plan-actions-list__header">
<div className="list-item-main"> <div className="list-item-main">
<Link to={actionPath(action.id)} className="plan-actions-list__title"> <Link to={actionPath(action.id)} className="plan-actions-list__title">
<strong>{action.title}</strong> <strong>{action.title}</strong>
@ -127,6 +138,14 @@ export function PlanActionsSection({
Öffnen Öffnen
</Link> </Link>
</div> </div>
</div>
<ActionTaskPanel
actionId={action.id}
roadmapItems={roadmapItems}
canManage={canManage}
expanded={expandedActions.has(action.id)}
onToggle={() => toggleActionTasks(action.id)}
/>
</li> </li>
))} ))}
</ul> </ul>

View File

@ -0,0 +1,179 @@
import { TASK_STATUSES, TASK_STATUS_LABELS } from '../constants/status.js'
import { gateTitleById } from './GateSelect.jsx'
import { MAX_TASK_UI_DEPTH } from '../utils/taskTree.js'
export function TaskForm({
initial = {},
parentTaskId = null,
parentLabel = null,
roadmapItems = [],
onSubmit,
onCancel,
busy = false,
submitLabel = 'Speichern',
}) {
async function handleSubmit(e) {
e.preventDefault()
const form = e.target
await onSubmit({
title: form.title.value.trim(),
description: form.description.value,
status: form.status?.value || initial.status || 'open',
roadmap_item_id: form.roadmap_item_id?.value || undefined,
parent_task_id: parentTaskId || undefined,
})
}
return (
<form className="form workspace-form" onSubmit={handleSubmit}>
{parentLabel && (
<p className="muted task-form__parent-hint">Unteraufgabe von: {parentLabel}</p>
)}
<label>
Titel
<input
name="title"
defaultValue={initial.title || ''}
required
maxLength={255}
autoFocus
/>
</label>
<label>
Beschreibung
<textarea name="description" rows={3} defaultValue={initial.description || ''} />
</label>
{roadmapItems.length > 0 && (
<label>
Gate (optional)
<select name="roadmap_item_id" defaultValue={initial.roadmap_item_id || ''}>
<option value=""> keins </option>
{roadmapItems.map((item) => (
<option key={item.id} value={item.id}>
{item.title}
</option>
))}
</select>
</label>
)}
{initial.id && (
<label>
Status
<select name="status" defaultValue={initial.status || 'open'}>
{TASK_STATUSES.map((status) => (
<option key={status} value={status}>
{TASK_STATUS_LABELS[status]}
</option>
))}
</select>
</label>
)}
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? 'Speichern …' : submitLabel}
</button>
{onCancel && (
<button type="button" className="btn btn-secondary" onClick={onCancel} disabled={busy}>
Abbrechen
</button>
)}
</div>
</form>
)
}
export function TaskTreeNodes({
tasksByParent,
parentId = '',
depth = 1,
roadmapItems,
canManage,
busy,
onEdit,
onAddSubtask,
onStatusChange,
onDelete,
}) {
const nodes = tasksByParent.get(parentId || '') || []
if (!nodes.length) return null
return (
<ul className={`task-tree task-tree--depth-${depth}`}>
{nodes.map((task) => (
<li key={task.id} className="task-tree__item">
<div className="task-tree__row">
<div className="task-tree__main">
<strong>{task.title}</strong>
{task.roadmap_item_id && (
<span className="muted task-tree__gate">
Gate: {gateTitleById(roadmapItems, task.roadmap_item_id)}
</span>
)}
</div>
<div className="task-tree__actions action-controls">
{canManage ? (
<>
<select
className="inline-select"
value={task.status}
disabled={busy}
onChange={(e) => onStatusChange(task.id, e.target.value)}
aria-label="Task-Status"
>
{TASK_STATUSES.map((status) => (
<option key={status} value={status}>
{TASK_STATUS_LABELS[status]}
</option>
))}
</select>
{depth < MAX_TASK_UI_DEPTH && (
<button
type="button"
className="btn btn-secondary btn-sm"
disabled={busy}
onClick={() => onAddSubtask(task)}
>
+ Unteraufgabe
</button>
)}
<button
type="button"
className="btn btn-secondary btn-sm"
disabled={busy}
onClick={() => onEdit(task)}
>
Bearbeiten
</button>
<button
type="button"
className="btn btn-secondary btn-sm"
disabled={busy}
onClick={() => onDelete(task.id)}
>
Entfernen
</button>
</>
) : (
<span>{TASK_STATUS_LABELS[task.status] || task.status}</span>
)}
</div>
</div>
{depth < MAX_TASK_UI_DEPTH && (
<TaskTreeNodes
tasksByParent={tasksByParent}
parentId={task.id}
depth={depth + 1}
roadmapItems={roadmapItems}
canManage={canManage}
busy={busy}
onEdit={onEdit}
onAddSubtask={onAddSubtask}
onStatusChange={onStatusChange}
onDelete={onDelete}
/>
)}
</li>
))}
</ul>
)
}

View File

@ -430,3 +430,68 @@
.plan-actions-section__scope-hint { .plan-actions-section__scope-hint {
margin: 0 0 12px; margin: 0 0 12px;
} }
.plan-actions-list__header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 12px;
}
.action-task-panel {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--jk-border-subtle, #e5e7eb);
}
.action-task-panel__toggle {
background: none;
border: none;
padding: 0;
font: inherit;
color: var(--jk-primary, #2563eb);
cursor: pointer;
}
.action-task-panel__body {
margin-top: 10px;
}
.action-task-panel__add {
margin-top: 10px;
}
.task-tree {
list-style: none;
margin: 0;
padding: 0;
}
.task-tree--depth-2,
.task-tree--depth-3 {
margin-left: 16px;
padding-left: 12px;
border-left: 2px solid var(--jk-border-subtle, #e5e7eb);
}
.task-tree__item {
margin-bottom: 8px;
}
.task-tree__row {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 8px;
align-items: flex-start;
}
.task-tree__gate {
display: block;
font-size: 13px;
}
.task-form__parent-hint {
margin: 0 0 12px;
font-size: 13px;
}

View File

@ -0,0 +1,56 @@
/** Task-Baum unter Action (AP1.5d) — UI max. 3 Ebenen. */
export const MAX_TASK_UI_DEPTH = 3
/**
* @param {Array<{ id: string, parent_task_id?: string | null, sort_order?: number, title?: string }>} tasks
* @returns {Map<string, Array<object>>}
*/
export function buildTasksByParent(tasks) {
const byParent = new Map()
for (const task of tasks) {
const parentKey = task.parent_task_id || ''
if (!byParent.has(parentKey)) {
byParent.set(parentKey, [])
}
byParent.get(parentKey).push(task)
}
for (const list of byParent.values()) {
list.sort(
(a, b) =>
(a.sort_order ?? 0) - (b.sort_order ?? 0) ||
(a.title || '').localeCompare(b.title || ''),
)
}
return byParent
}
/**
* @param {Map<string, Array<object>>} byParent
* @param {string | null | undefined} parentId
* @param {number} [depth]
* @returns {number}
*/
export function countOpenTasksInSubtree(byParent, parentId = '', depth = 1) {
const nodes = byParent.get(parentId || '') || []
let count = 0
for (const task of nodes) {
if (task.status !== 'done' && task.status !== 'discarded') {
count += 1
}
if (depth < MAX_TASK_UI_DEPTH) {
count += countOpenTasksInSubtree(byParent, task.id, depth + 1)
}
}
return count
}
/**
* @param {Array<object>} tasks
* @returns {number}
*/
export function countOpenTasks(tasks) {
if (!tasks?.length) return 0
const byParent = buildTasksByParent(tasks)
return countOpenTasksInSubtree(byParent)
}

View File

@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { buildTasksByParent, countOpenTasks } from './taskTree.js'
describe('taskTree', () => {
it('groups tasks by parent and sorts by sort_order', () => {
const byParent = buildTasksByParent([
{ id: 'b', parent_task_id: 'a', sort_order: 2, title: 'B', status: 'open' },
{ id: 'a', parent_task_id: null, sort_order: 1, title: 'A', status: 'open' },
{ id: 'c', parent_task_id: 'a', sort_order: 1, title: 'C', status: 'done' },
])
expect(byParent.get('').map((t) => t.id)).toEqual(['a'])
expect(byParent.get('a').map((t) => t.id)).toEqual(['c', 'b'])
})
it('counts open tasks in subtree', () => {
const tasks = [
{ id: 'a', parent_task_id: null, status: 'open' },
{ id: 'b', parent_task_id: 'a', status: 'done' },
{ id: 'c', parent_task_id: 'a', status: 'open' },
]
expect(countOpenTasks(tasks)).toBe(2)
})
})