Kanban: Checklisten und Voraussetzungen zwischen Aufgaben

- Checkliste je Aufgabe (Tabelle kanban_checklist): im Dialog anlegen,
  abhaken, entfernen; Karte zeigt den Fortschritt
- "Setzt voraus" (Tabelle kanban_dependencies): Aufgabe ist gesperrt und
  bleibt in "Offen", bis alle Voraussetzungen erledigt sind; nur innerhalb
  eines Projekts, Kreise und Selbstbezug werden abgelehnt; gelöschte
  Voraussetzungen entsperren, archivierte zählen als erledigt
- Ziehen einer gesperrten Karte aus "Offen" zeigt, worauf sie wartet
- Migrationen: Tabellen-Neuaufbau über Zwischennamen, damit Fremdschlüssel
  anderer Tabellen nicht auf die gelöschte Alt-Tabelle umgebogen werden
  (Test für ganz alte Datenbanken)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-15 19:10:10 +02:00
1 parent a1a1998d1e
commit 9df0e38f52
10 files changed
+375 -28

No files matched your search

+4
View File
@@ -199,6 +199,10 @@ haben Titel, Beschreibung, Status und Bearbeiter; verschieben per Drag & Drop od
Projekte lassen sich mit Artikeln aus „P42 Projekte“ verknüpfen, abschließen, wieder öffnen Projekte lassen sich mit Artikeln aus „P42 Projekte“ verknüpfen, abschließen, wieder öffnen
und löschen (löschen: wer angelegt hat und Administratoren). und löschen (löschen: wer angelegt hat und Administratoren).
- **Checkliste** je Aufgabe: Punkte im Aufgaben-Dialog anlegen und abhaken; die Karte zeigt den Stand (☑ 2/5).
- **Voraussetzungen:** Im Dialog unter „Setzt voraus“ andere Aufgaben desselben Projekts wählen. Solange eine
davon nicht erledigt ist, ist die Aufgabe gesperrt (🔒, bleibt in „Offen“). Kreise werden abgelehnt.
Erledigte Aufgaben räumen sich selbst auf: **2 Tage** nach „Erledigt“ verschwinden sie ins **Archiv** Erledigte Aufgaben räumen sich selbst auf: **2 Tage** nach „Erledigt“ verschwinden sie ins **Archiv**
(Button „Archiv (n)“ im Board, dort auch „Zurückholen“), **14 Tage** nach „Erledigt“ werden sie (Button „Archiv (n)“ im Board, dort auch „Zurückholen“), **14 Tage** nach „Erledigt“ werden sie
automatisch gelöscht. Zurück aus „Erledigt“ hält die Uhr an; „Zurückholen“ startet beide Fristen neu. automatisch gelöscht. Zurück aus „Erledigt“ hält die Uhr an; „Zurückholen“ startet beide Fristen neu.
+17
View File
@@ -204,6 +204,23 @@ CREATE TABLE IF NOT EXISTS kanban_project_links (
CREATE TABLE IF NOT EXISTS kanban_tasks {KANBAN_TASKS_TABLE}; CREATE TABLE IF NOT EXISTS kanban_tasks {KANBAN_TASKS_TABLE};
-- Checkliste innerhalb einer Aufgabe
CREATE TABLE IF NOT EXISTS kanban_checklist (
id INTEGER PRIMARY KEY,
task_id INTEGER NOT NULL REFERENCES kanban_tasks(id) ON DELETE CASCADE,
text TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
position INTEGER NOT NULL
);
-- Abhängigkeiten: task_id ist gesperrt, bis requires_id erledigt ist (nur innerhalb eines Projekts)
CREATE TABLE IF NOT EXISTS kanban_dependencies (
task_id INTEGER NOT NULL REFERENCES kanban_tasks(id) ON DELETE CASCADE,
requires_id INTEGER NOT NULL REFERENCES kanban_tasks(id) ON DELETE CASCADE,
PRIMARY KEY (task_id, requires_id),
CHECK (task_id != requires_id)
);
CREATE TABLE IF NOT EXISTS meta ( CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL value TEXT NOT NULL
+117 -3
View File
@@ -25,6 +25,9 @@ MAX_DESCRIPTION = 5000
MAX_TASKS = 1000 # pro Projekt MAX_TASKS = 1000 # pro Projekt
MAX_PROJECTS = 300 MAX_PROJECTS = 300
MAX_LINKS = 20 MAX_LINKS = 20
MAX_CHECKLIST = 100
MAX_CHECK_TEXT = 200
MAX_REQUIRES = 30
ARCHIVE_AFTER = timedelta(days=2) ARCHIVE_AFTER = timedelta(days=2)
DELETE_AFTER = timedelta(days=14) DELETE_AFTER = timedelta(days=14)
CLEANUP_SECONDS = 3600 CLEANUP_SECONDS = 3600
@@ -194,6 +197,9 @@ def _task(row) -> dict:
"priority": PRIORITY_NAMES[row["priority"]], "priority": PRIORITY_NAMES[row["priority"]],
"position": row["position"], "position": row["position"],
"doneAt": row["done_at"], "doneAt": row["done_at"],
"checklist": [],
"requires": [],
"blocked": False,
"createdAt": row["created_at"], "createdAt": row["created_at"],
"createdBy": _person(row["created_by"], row["created_by_name"]), "createdBy": _person(row["created_by"], row["created_by_name"]),
"updatedAt": row["updated_at"], "updatedAt": row["updated_at"],
@@ -201,6 +207,95 @@ def _task(row) -> dict:
} }
def _task_extras(conn, project_id: int) -> dict:
"""Checklisten, Voraussetzungen und "gesperrt" für alle Aufgaben eines Projekts."""
extras = {}
for r in conn.execute(
"""SELECT c.* FROM kanban_checklist c JOIN kanban_tasks t ON t.id = c.task_id
WHERE t.project_id = ? ORDER BY c.task_id, c.position""",
(project_id,),
):
extras.setdefault(r["task_id"], {"checklist": [], "requires": [], "blocked": False})["checklist"].append(
{"id": r["id"], "text": r["text"], "done": bool(r["done"])}
)
for r in conn.execute(
"""SELECT d.task_id, d.requires_id, rt.status FROM kanban_dependencies d
JOIN kanban_tasks t ON t.id = d.task_id JOIN kanban_tasks rt ON rt.id = d.requires_id
WHERE t.project_id = ?""",
(project_id,),
):
e = extras.setdefault(r["task_id"], {"checklist": [], "requires": [], "blocked": False})
e["requires"].append(r["requires_id"])
e["blocked"] = e["blocked"] or r["status"] != "erledigt"
return extras
def _open_requirements(conn, task_id: int) -> list:
"""Titel der noch nicht erledigten Voraussetzungen einer Aufgabe."""
return [r["title"] for r in conn.execute(
"""SELECT t.title FROM kanban_dependencies d JOIN kanban_tasks t ON t.id = d.requires_id
WHERE d.task_id = ? AND t.status != 'erledigt' ORDER BY t.title""",
(task_id,),
)]
def _checklist(value) -> list:
if not isinstance(value, list) or len(value) > MAX_CHECKLIST:
raise ValidationError(f"Ungültige Checkliste (max. {MAX_CHECKLIST} Punkte).")
items = []
for item in value:
if not isinstance(item, dict) or not isinstance(item.get("done", False), bool):
raise ValidationError("Ungültiger Checklisten-Punkt.")
items.append((_name(item.get("text"), "Checklisten-Punkt", MAX_CHECK_TEXT), item.get("done", False)))
return items
def _save_checklist(conn, task_id: int, items: list) -> None:
conn.execute("DELETE FROM kanban_checklist WHERE task_id = ?", (task_id,))
conn.executemany(
"INSERT INTO kanban_checklist (task_id, text, done, position) VALUES (?, ?, ?, ?)",
[(task_id, text, int(done), i) for i, (text, done) in enumerate(items)],
)
def _requires(conn, value, project_id: int, task_id=None) -> list:
"""Voraussetzungen prüfen: Aufgaben desselben Projekts, nicht sie selbst, keine Kreise."""
if not isinstance(value, list) or len(value) > MAX_REQUIRES or any(isinstance(v, bool) or not isinstance(v, int) for v in value):
raise ValidationError("Ungültige Voraussetzungen.")
ids = list(dict.fromkeys(value))
if task_id in ids:
raise ValidationError("Eine Aufgabe kann nicht sich selbst voraussetzen.")
if ids:
found = {r[0] for r in conn.execute(
f"SELECT id FROM kanban_tasks WHERE project_id = ? AND id IN ({','.join('?' * len(ids))})", (project_id, *ids)
)}
if found != set(ids):
raise ValidationError("Voraussetzungen müssen Aufgaben aus diesem Projekt sein.")
if task_id is not None:
# Kreis: hängt eine der Voraussetzungen (über Umwege) von dieser Aufgabe ab?
seen, todo = set(), list(ids)
while todo:
current = todo.pop()
if current == task_id:
raise ValidationError("Das ergäbe einen Kreis: diese Aufgabe ist selbst Voraussetzung einer der gewählten.")
if current in seen:
continue
seen.add(current)
todo += [r[0] for r in conn.execute("SELECT requires_id FROM kanban_dependencies WHERE task_id = ?", (current,))]
return ids
def _save_requires(conn, task_id: int, ids: list) -> None:
conn.execute("DELETE FROM kanban_dependencies WHERE task_id = ?", (task_id,))
conn.executemany("INSERT INTO kanban_dependencies (task_id, requires_id) VALUES (?, ?)", [(task_id, r) for r in ids])
def _ensure_unlocked(conn, task_id: int, status: str) -> None:
"""Gesperrte Aufgaben bleiben in "Offen", bis alle Voraussetzungen erledigt sind."""
if status != "offen" and (missing := _open_requirements(conn, task_id)):
raise ValidationError("Gesperrt – erst erledigen: " + ", ".join(f"„{t}“" for t in missing))
def _board(conn, project_id: int) -> dict: def _board(conn, project_id: int) -> dict:
projects = _projects(conn, project_id) projects = _projects(conn, project_id)
if not projects: if not projects:
@@ -223,11 +318,14 @@ def _board(conn, project_id: int) -> dict:
reverse=True, reverse=True,
) )
archived_ids = {r["id"] for r in archived} archived_ids = {r["id"] for r in archived}
extras = _task_extras(conn, project_id)
return { return {
"project": project, "project": project,
"tasks": [_task(r) for r in tasks if r["id"] not in archived_ids], "tasks": [{**_task(r), **extras.get(r["id"], {})} for r in tasks if r["id"] not in archived_ids],
# Archiv: nur mit eigenem Button sichtbar; deleteAt = automatisches Löschen # Archiv: nur mit eigenem Button sichtbar; deleteAt = automatisches Löschen
"archived": [{**_task(r), "deleteAt": iso_after(r["done_at"], DELETE_AFTER)} for r in archived], "archived": [
{**_task(r), **extras.get(r["id"], {}), "deleteAt": iso_after(r["done_at"], DELETE_AFTER)} for r in archived
],
"users": user_list(conn), "users": user_list(conn),
"articles": [{"id": a["id"], "title": a["title"]} for a in articles], "articles": [{"id": a["id"], "title": a["title"]} for a in articles],
} }
@@ -330,9 +428,11 @@ def create_task(project_id: int, data: dict = Body(...), user: dict = Depends(cu
description = _description(data.get("description")) description = _description(data.get("description"))
status = _status(data.get("status", "offen")) status = _status(data.get("status", "offen"))
priority = _priority(data.get("priority", "mittel")) priority = _priority(data.get("priority", "mittel"))
checklist = _checklist(data.get("checklist", []))
with transaction() as conn: with transaction() as conn:
if not conn.execute("SELECT 1 FROM kanban_projects WHERE id = ?", (project_id,)).fetchone(): if not conn.execute("SELECT 1 FROM kanban_projects WHERE id = ?", (project_id,)).fetchone():
raise HTTPException(404, "Kanban-Projekt nicht gefunden.") raise HTTPException(404, "Kanban-Projekt nicht gefunden.")
requires = _requires(conn, data.get("requires", []), project_id)
if conn.execute("SELECT COUNT(*) FROM kanban_tasks WHERE project_id = ?", (project_id,)).fetchone()[0] >= MAX_TASKS: if conn.execute("SELECT COUNT(*) FROM kanban_tasks WHERE project_id = ?", (project_id,)).fetchone()[0] >= MAX_TASKS:
raise ValidationError(f"Maximal {MAX_TASKS} Aufgaben pro Projekt. Bitte erledigte Aufgaben löschen.") raise ValidationError(f"Maximal {MAX_TASKS} Aufgaben pro Projekt. Bitte erledigte Aufgaben löschen.")
now = now_iso() now = now_iso()
@@ -343,6 +443,10 @@ def create_task(project_id: int, data: dict = Body(...), user: dict = Depends(cu
(project_id, title, description, status, _assignee(conn, data.get("assignee")), priority, (project_id, title, description, status, _assignee(conn, data.get("assignee")), priority,
_end_of(conn, project_id, status), now if status == "erledigt" else None, now, user["id"], now, user["id"]), _end_of(conn, project_id, status), now if status == "erledigt" else None, now, user["id"], now, user["id"]),
) )
task_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
_save_checklist(conn, task_id, checklist)
_save_requires(conn, task_id, requires)
_ensure_unlocked(conn, task_id, status)
return _board(conn, project_id) return _board(conn, project_id)
@@ -370,7 +474,17 @@ def update_task(task_id: int, data: dict = Body(...), user: dict = Depends(curre
elif values.get("status", row["status"]) != row["status"] or values.get("priority", row["priority"]) != row["priority"]: elif values.get("status", row["status"]) != row["status"] or values.get("priority", row["priority"]) != row["priority"]:
# Neue Spalte oder neue Priorität: ans Ende (innerhalb der Stufe, da nach Priorität sortiert wird) # Neue Spalte oder neue Priorität: ans Ende (innerhalb der Stufe, da nach Priorität sortiert wird)
values["position"] = _end_of(conn, row["project_id"], values.get("status", row["status"])) values["position"] = _end_of(conn, row["project_id"], values.get("status", row["status"]))
if values: changed = bool(values)
if "checklist" in data:
_save_checklist(conn, task_id, _checklist(data["checklist"]))
changed = True
if "requires" in data:
_save_requires(conn, task_id, _requires(conn, data["requires"], row["project_id"], task_id))
changed = True
# Gesperrt: darf nicht aus "Offen" heraus. War sie schon weiter, bleibt sie, wo sie ist.
if values.get("status", row["status"]) != row["status"]:
_ensure_unlocked(conn, task_id, values["status"])
if changed:
values.update(updated_at=now_iso(), updated_by=user["id"]) values.update(updated_at=now_iso(), updated_by=user["id"])
columns = ", ".join(f"{k} = ?" for k in values) # Schlüssel stammen nur aus der Liste oben columns = ", ".join(f"{k} = ?" for k in values) # Schlüssel stammen nur aus der Liste oben
conn.execute(f"UPDATE kanban_tasks SET {columns} WHERE id = ?", (*values.values(), task_id)) conn.execute(f"UPDATE kanban_tasks SET {columns} WHERE id = ?", (*values.values(), task_id))
+26 -17
View File
@@ -32,6 +32,20 @@ def upgrade(conn) -> None:
conn.execute(f"PRAGMA user_version = {number}") conn.execute(f"PRAGMA user_version = {number}")
def _rebuild(conn, table: str, definition: str, columns: str, select: str, params=()) -> None:
"""Tabelle neu aufbauen: neue Tabelle unter Zwischennamen anlegen, Daten kopieren, alte löschen, umbenennen.
So herum bleiben Fremdschlüssel anderer Tabellen (z. B. Checkliste → Aufgaben) auf den ursprünglichen
Namen gerichtet. Würde man die alte Tabelle zuerst umbenennen, schriebe SQLite deren Verweise auf den
Zwischennamen um – und nach dem Löschen zeigten sie ins Leere.
select liest aus der alten Tabelle (unter ihrem ursprünglichen Namen).
"""
conn.execute(f"CREATE TABLE {table}_neu {definition}")
conn.execute(f"INSERT INTO {table}_neu ({columns}) {select}", params)
conn.execute(f"DROP TABLE {table}")
conn.execute(f"ALTER TABLE {table}_neu RENAME TO {table}")
# ---------- Schritt 1: Altbestand aus der Zeit vor der Versionsnummer ---------- # ---------- Schritt 1: Altbestand aus der Zeit vor der Versionsnummer ----------
# Diese Umstellungen prüfen noch selbst, ob sie nötig sind – eine Datenbank ohne Nummer kann # Diese Umstellungen prüfen noch selbst, ob sie nötig sind – eine Datenbank ohne Nummer kann
# auf jedem früheren Stand sein. Reihenfolge wie früher beim Start. # auf jedem früheren Stand sein. Reihenfolge wie früher beim Start.
@@ -71,14 +85,12 @@ def _household(conn) -> None:
list_id = "COALESCE(list_id, ?)" if "list_id" in info else "?" list_id = "COALESCE(list_id, ?)" if "list_id" in info else "?"
conn.execute("DROP INDEX IF EXISTS household_vorrat") conn.execute("DROP INDEX IF EXISTS household_vorrat")
conn.execute("DROP INDEX IF EXISTS household_einkauf") conn.execute("DROP INDEX IF EXISTS household_einkauf")
conn.execute("ALTER TABLE household_items RENAME TO household_items_alt") _rebuild(
conn.execute("CREATE TABLE household_items " + HOUSEHOLD_TABLE) conn, "household_items", HOUSEHOLD_TABLE,
conn.execute( "id, key, name, list, list_id, quantity, unit, updated_at, updated_by",
f"""INSERT INTO household_items (id, key, name, list, list_id, quantity, unit, updated_at, updated_by) f"SELECT id, key, name, list, {list_id}, quantity, {unit}, updated_at, updated_by FROM household_items",
SELECT id, key, name, list, {list_id}, quantity, {unit}, updated_at, updated_by FROM household_items_alt""",
(default,), (default,),
) )
conn.execute("DROP TABLE household_items_alt")
def _household_checked(conn) -> None: def _household_checked(conn) -> None:
@@ -166,22 +178,19 @@ def _kanban_projects(conn) -> None:
columns = {r["name"] for r in conn.execute("PRAGMA table_info(kanban_tasks)")} columns = {r["name"] for r in conn.execute("PRAGMA table_info(kanban_tasks)")}
if "project_id" in columns: if "project_id" in columns:
return return
conn.execute("ALTER TABLE kanban_tasks RENAME TO kanban_tasks_alt") project = None
conn.execute("CREATE TABLE kanban_tasks " + KANBAN_TASKS_TABLE) if conn.execute("SELECT COUNT(*) FROM kanban_tasks").fetchone()[0]:
if conn.execute("SELECT COUNT(*) FROM kanban_tasks_alt").fetchone()[0]:
now = now_iso() now = now_iso()
project = conn.execute( project = conn.execute(
"INSERT INTO kanban_projects (name, position, created_at, updated_at) VALUES ('Allgemeines Board', 1, ?, ?)", "INSERT INTO kanban_projects (name, position, created_at, updated_at) VALUES ('Allgemeines Board', 1, ?, ?)",
(now, now), (now, now),
).lastrowid ).lastrowid
conn.execute( _rebuild(
"""INSERT INTO kanban_tasks (id, project_id, title, description, status, assignee, position, conn, "kanban_tasks", KANBAN_TASKS_TABLE,
created_at, created_by, updated_at, updated_by) "id, project_id, title, description, status, assignee, position, created_at, created_by, updated_at, updated_by",
SELECT id, ?, title, description, status, assignee, position, created_at, created_by, updated_at, updated_by "SELECT id, ?, title, description, status, assignee, position, created_at, created_by, updated_at, updated_by FROM kanban_tasks",
FROM kanban_tasks_alt""", (project,),
(project,), )
)
conn.execute("DROP TABLE kanban_tasks_alt")
# ---------- Schritt 2: Datenschutz ---------- # ---------- Schritt 2: Datenschutz ----------
+62
View File
@@ -63,3 +63,65 @@ def test_loeschen_nur_ersteller_oder_admin(login):
assert ben.delete(f"/api/kanban/aufgaben/{t1['id']}").status_code == 403 assert ben.delete(f"/api/kanban/aufgaben/{t1['id']}").status_code == 403
assert anna.delete(f"/api/kanban/aufgaben/{t1['id']}").status_code == 200 assert anna.delete(f"/api/kanban/aufgaben/{t1['id']}").status_code == 200
assert chef.delete(f"/api/kanban/aufgaben/{t2['id']}").status_code == 200 assert chef.delete(f"/api/kanban/aufgaben/{t2['id']}").status_code == 200
# ---------- Checklisten und Abhängigkeiten ----------
def find(data, title):
return next(t for t in data["tasks"] + data["archived"] if t["title"] == title)
def test_checkliste_speichern_und_abhaken(login):
anna = login("anna")
pid = make_board(anna)
data = anna.post(f"/api/kanban/projekte/{pid}/aufgaben", json={
"title": "Umzug", "checklist": [{"text": "Kartons"}, {"text": "Transporter", "done": True}],
}).json()
t = find(data, "Umzug")
assert [(c["text"], c["done"]) for c in t["checklist"]] == [("Kartons", False), ("Transporter", True)]
data = anna.patch(f"/api/kanban/aufgaben/{t['id']}", json={"checklist": [{"text": "Kartons", "done": True}]}).json()
assert [(c["text"], c["done"]) for c in find(data, "Umzug")["checklist"]] == [("Kartons", True)]
assert anna.patch(f"/api/kanban/aufgaben/{t['id']}", json={"checklist": [{"text": " "}]}).status_code == 400
def test_voraussetzung_sperrt_bis_erledigt(login):
anna = login("anna")
pid = make_board(anna)
a = task(anna, pid, "Wand streichen")
data = anna.post(f"/api/kanban/projekte/{pid}/aufgaben", json={"title": "Bilder aufhängen", "requires": [a["id"]]}).json()
b = find(data, "Bilder aufhängen")
assert b["blocked"] is True and b["requires"] == [a["id"]]
res = anna.patch(f"/api/kanban/aufgaben/{b['id']}", json={"status": "arbeit"})
assert res.status_code == 400 and "Wand streichen" in res.json()["error"]
anna.patch(f"/api/kanban/aufgaben/{a['id']}", json={"status": "erledigt"})
data = anna.patch(f"/api/kanban/aufgaben/{b['id']}", json={"status": "arbeit"}).json()
assert find(data, "Bilder aufhängen")["blocked"] is False
def test_gesperrt_anlegen_nur_in_offen(login):
anna = login("anna")
pid = make_board(anna)
a = task(anna, pid, "A")
res = anna.post(f"/api/kanban/projekte/{pid}/aufgaben", json={"title": "B", "status": "arbeit", "requires": [a["id"]]})
assert res.status_code == 400
def test_kein_kreis_und_nur_eigenes_projekt(login):
anna = login("anna")
pid, other = make_board(anna), make_board(anna)
a, b = task(anna, pid, "A"), task(anna, pid, "B")
fremd = task(anna, other, "Fremd")
assert anna.patch(f"/api/kanban/aufgaben/{b['id']}", json={"requires": [a["id"]]}).status_code == 200
assert anna.patch(f"/api/kanban/aufgaben/{a['id']}", json={"requires": [b["id"]]}).status_code == 400 # Kreis
assert anna.patch(f"/api/kanban/aufgaben/{a['id']}", json={"requires": [a["id"]]}).status_code == 400 # selbst
assert anna.patch(f"/api/kanban/aufgaben/{a['id']}", json={"requires": [fremd["id"]]}).status_code == 400
def test_geloeschte_voraussetzung_entsperrt(login):
anna = login("anna")
pid = make_board(anna)
a = task(anna, pid, "A")
data = anna.post(f"/api/kanban/projekte/{pid}/aufgaben", json={"title": "B", "requires": [a["id"]]}).json()
anna.delete(f"/api/kanban/aufgaben/{a['id']}")
assert find(board(anna, pid), "B")["blocked"] is False
+14
View File
@@ -158,3 +158,17 @@ def test_schritt_laeuft_nur_einmal(old_db, monkeypatch):
db.init() db.init()
assert calls == [1] assert calls == [1]
assert conn.execute("PRAGMA user_version").fetchone()[0] == len(migrations.STEPS) assert conn.execute("PRAGMA user_version").fetchone()[0] == len(migrations.STEPS)
def test_uralte_aufgaben_umbau_laesst_neue_tabellen_heil(old_db):
"""Aufgaben ohne Projekt (ganz alter Stand): Umbau darf die Verweise von Checkliste/Abhängigkeiten nicht verbiegen."""
conn = old_db(
"""CREATE TABLE kanban_tasks (id INTEGER PRIMARY KEY, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL, assignee INTEGER, position REAL NOT NULL, created_at TEXT NOT NULL, created_by INTEGER,
updated_at TEXT NOT NULL, updated_by INTEGER)""",
f"INSERT INTO kanban_tasks VALUES (1, 'alt', '', 'offen', NULL, 1, '{T}', NULL, '{T}', NULL)",
)
with conn:
conn.execute("INSERT INTO kanban_checklist (task_id, text, done, position) VALUES (1, 'x', 0, 0)")
sql = conn.execute("SELECT sql FROM sqlite_master WHERE name = 'kanban_checklist'").fetchone()[0]
assert "kanban_tasks_alt" not in sql
+22 -2
View File
@@ -13,11 +13,21 @@ export function Assignee({ id, users }) {
); );
} }
export default function TaskCard({ task, users, dragging, onOpen, onDragStart, onDragEnd }) { // Titel der noch offenen Voraussetzungen einer Aufgabe
export function waitingFor(task, tasks) {
return (task.requires || [])
.map((id) => tasks.find((t) => t.id === id))
.filter((t) => t && t.status !== 'erledigt')
.map((t) => t.title);
}
export default function TaskCard({ task, users, waitingFor: waiting = [], dragging, onOpen, onDragStart, onDragEnd }) {
const done = task.checklist?.filter((c) => c.done).length ?? 0;
const total = task.checklist?.length ?? 0;
return ( return (
<button <button
type="button" type="button"
className={`task-card prio-card-${task.priority}${dragging ? ' dragging' : ''}`} className={`task-card prio-card-${task.priority}${dragging ? ' dragging' : ''}${task.blocked ? ' blocked' : ''}`}
draggable draggable
onDragStart={onDragStart} onDragStart={onDragStart}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
@@ -32,8 +42,18 @@ export default function TaskCard({ task, users, dragging, onOpen, onDragStart, o
)} )}
</span> </span>
{task.description && <span className="task-desc">{task.description}</span>} {task.description && <span className="task-desc">{task.description}</span>}
{task.blocked && (
<span className="task-blocked" title="Gesperrt, bis die Voraussetzungen erledigt sind">
<span aria-hidden="true">🔒</span> wartet auf {waiting.join(', ') || 'Voraussetzungen'}
</span>
)}
<span className="task-meta"> <span className="task-meta">
<Assignee id={task.assignee} users={users} /> <Assignee id={task.assignee} users={users} />
{total > 0 && (
<span className={`task-checks${done === total ? ' complete' : ''}`} title="Checkliste">
☑ {done}/{total}
</span>
)}
</span> </span>
</button> </button>
); );
+90 -5
View File
@@ -2,28 +2,59 @@ import { useState } from 'react';
import { fmtDate, personName } from '../../api.js'; import { fmtDate, personName } from '../../api.js';
import { COLUMNS, PRIORITIES } from './board.js'; import { COLUMNS, PRIORITIES } from './board.js';
// Aufgabe anlegen oder bearbeiten (Titel, Beschreibung, Status, Priorität, Bearbeiter), löschen // Aufgabe anlegen oder bearbeiten: Titel, Beschreibung, Checkliste, Status, Priorität, Bearbeiter,
// Voraussetzungen (gesperrt, bis diese erledigt sind); löschen
export default function TaskDialog({ ref, target, users, me, onSave, onDelete }) { export default function TaskDialog({ ref, target, users, tasks = [], me, onSave, onDelete }) {
const task = target?.task; const task = target?.task;
// Wird pro Öffnen neu erzeugt (key), daher direkt aus den Daten vorbelegen // Wird pro Öffnen neu erzeugt (key), daher direkt aus den Daten vorbelegen
const [form, setForm] = useState(() => (task const [form, setForm] = useState(() => (task
? { title: task.title, description: task.description, status: task.status, assignee: task.assignee ?? '', priority: task.priority || 'mittel' } ? {
: { title: '', description: '', status: target?.status || 'offen', assignee: '', priority: 'mittel' })); title: task.title, description: task.description, status: task.status, assignee: task.assignee ?? '',
priority: task.priority || 'mittel', checklist: task.checklist || [], requires: task.requires || [],
}
: { title: '', description: '', status: target?.status || 'offen', assignee: '', priority: 'mittel', checklist: [], requires: [] }));
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [newCheck, setNewCheck] = useState('');
const [error, setError] = useState(null);
// Mögliche Voraussetzungen: andere Aufgaben des Projekts (gewählte zuerst)
const others = tasks
.filter((t) => t.id !== task?.id)
.sort((a, b) => Number(form.requires.includes(b.id)) - Number(form.requires.includes(a.id)) || a.title.localeCompare(b.title, 'de'));
const missing = form.requires.map((id) => tasks.find((t) => t.id === id)).filter((t) => t && t.status !== 'erledigt');
const blocked = missing.length > 0;
const toggleRequire = (id) => setForm((f) => ({ ...f, requires: f.requires.includes(id) ? f.requires.filter((x) => x !== id) : [...f.requires, id] }));
const setCheck = (index, patch) => setForm((f) => ({ ...f, checklist: f.checklist.map((c, i) => (i === index ? { ...c, ...patch } : c)) }));
const removeCheck = (index) => setForm((f) => ({ ...f, checklist: f.checklist.filter((_, i) => i !== index) }));
function addCheck() {
const text = newCheck.trim();
if (!text) return;
setForm((f) => ({ ...f, checklist: [...f.checklist, { text, done: false }] }));
setNewCheck('');
}
const field = (name) => ({ value: form[name], onChange: (e) => setForm((f) => ({ ...f, [name]: e.target.value })) }); const field = (name) => ({ value: form[name], onChange: (e) => setForm((f) => ({ ...f, [name]: e.target.value })) });
const canDelete = task && (me.isAdmin || task.createdBy?.id === me.id); const canDelete = task && (me.isAdmin || task.createdBy?.id === me.id);
async function submit(e) { async function submit(e) {
e.preventDefault(); e.preventDefault();
// Noch nicht hinzugefügter Checklisten-Text soll nicht verloren gehen
const checklist = newCheck.trim() ? [...form.checklist, { text: newCheck.trim(), done: false }] : form.checklist;
if (blocked && form.status !== 'offen' && form.status !== task?.status) {
setError(`Gesperrt – erst erledigen: ${missing.map((t) => `„${t.title}“`).join(', ')}`);
return;
}
setBusy(true); setBusy(true);
setError(null);
const ok = await onSave(task?.id, { const ok = await onSave(task?.id, {
title: form.title, title: form.title,
description: form.description, description: form.description,
status: form.status, status: form.status,
assignee: form.assignee === '' ? null : Number(form.assignee), assignee: form.assignee === '' ? null : Number(form.assignee),
priority: form.priority, priority: form.priority,
checklist: checklist.map(({ text, done }) => ({ text, done })),
requires: form.requires,
}); });
setBusy(false); setBusy(false);
if (ok) ref.current.close(); if (ok) ref.current.close();
@@ -49,11 +80,45 @@ export default function TaskDialog({ ref, target, users, me, onSave, onDelete })
Beschreibung Beschreibung
<textarea {...field('description')} rows={6} maxLength={5000} placeholder="Was ist zu tun? Details, Links, Notizen …" /> <textarea {...field('description')} rows={6} maxLength={5000} placeholder="Was ist zu tun? Details, Links, Notizen …" />
</label> </label>
<fieldset className="link-picker checklist-editor">
<legend>
Checkliste
{form.checklist.length > 0 && <small> ({form.checklist.filter((c) => c.done).length}/{form.checklist.length} erledigt)</small>}
</legend>
{form.checklist.length > 0 && (
<ul>
{form.checklist.map((c, i) => (
<li key={i} className="checklist-item">
<label className="checkbox">
<input type="checkbox" checked={c.done} onChange={(e) => setCheck(i, { done: e.target.checked })} />
<span className={c.done ? 'done' : undefined}>{c.text}</span>
</label>
<button className="link-btn danger" type="button" onClick={() => removeCheck(i)} aria-label={`„${c.text}“ entfernen`}>✕</button>
</li>
))}
</ul>
)}
<div className="checklist-add">
<input
value={newCheck}
onChange={(e) => setNewCheck(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addCheck(); } }}
placeholder="Neuer Punkt, Enter zum Hinzufügen"
maxLength={200}
aria-label="Neuer Checklisten-Punkt"
/>
<button className="btn ghost" type="button" onClick={addCheck} disabled={!newCheck.trim()}>Hinzufügen</button>
</div>
</fieldset>
<div className="task-dialog-row"> <div className="task-dialog-row">
<label> <label>
Status Status
<select {...field('status')}> <select {...field('status')}>
{COLUMNS.map((c) => <option key={c.key} value={c.key}>{c.title}</option>)} {COLUMNS.map((c) => (
<option key={c.key} value={c.key} disabled={blocked && c.key !== 'offen' && c.key !== task?.status}>
{c.title}{blocked && c.key !== 'offen' && c.key !== task?.status ? ' (gesperrt)' : ''}
</option>
))}
</select> </select>
</label> </label>
<label> <label>
@@ -70,6 +135,26 @@ export default function TaskDialog({ ref, target, users, me, onSave, onDelete })
</select> </select>
</label> </label>
</div> </div>
<fieldset className="link-picker">
<legend>Setzt voraus <small>– gesperrt, bis diese Aufgaben erledigt sind</small></legend>
{others.length === 0 ? (
<p className="hint">Keine anderen Aufgaben in diesem Projekt.</p>
) : (
<ul className="requires-list">
{others.map((t) => (
<li key={t.id}>
<label className="checkbox">
<input type="checkbox" checked={form.requires.includes(t.id)} onChange={() => toggleRequire(t.id)} />
{t.title}
<small>{t.status === 'erledigt' ? ' ✓ erledigt' : ''}</small>
</label>
</li>
))}
</ul>
)}
{blocked && <p className="hint task-blocked">🔒 Gesperrt, bis erledigt: {missing.map((t) => t.title).join(', ')}</p>}
</fieldset>
{error && <p className="error">{error}</p>}
{task && ( {task && (
<p className="hint task-history"> <p className="hint task-history">
Angelegt von {personName(task.createdBy)} am {fmtDate(task.createdAt)} Angelegt von {personName(task.createdBy)} am {fmtDate(task.createdAt)}
+11 -1
View File
@@ -4,7 +4,7 @@ import { api, fmtDate } from '../api.js';
import { useAuth } from '../auth.jsx'; import { useAuth } from '../auth.jsx';
import { byPriority, COLUMNS, positionAt } from '../components/kanban/board.js'; import { byPriority, COLUMNS, positionAt } from '../components/kanban/board.js';
import KanbanProjectDialog from '../components/kanban/KanbanProjectDialog.jsx'; import KanbanProjectDialog from '../components/kanban/KanbanProjectDialog.jsx';
import TaskCard, { Assignee } from '../components/kanban/TaskCard.jsx'; import TaskCard, { Assignee, waitingFor } from '../components/kanban/TaskCard.jsx';
import TaskDialog from '../components/kanban/TaskDialog.jsx'; import TaskDialog from '../components/kanban/TaskDialog.jsx';
import useLiveData from '../useLiveData.js'; import useLiveData from '../useLiveData.js';
@@ -92,6 +92,8 @@ export default function KanbanBoard() {
: filter === 'niemand' ? t.assignee == null : filter === 'niemand' ? t.assignee == null
: t.assignee === Number(filter)); : t.assignee === Number(filter));
const visible = (data?.tasks || []).filter(matches); const visible = (data?.tasks || []).filter(matches);
// Für Voraussetzungen: auch archivierte Aufgaben kennen (sie zählen als erledigt)
const allTasks = [...(data?.tasks || []), ...(data?.archived || [])];
const archived = (data?.archived || []).filter(matches); const archived = (data?.archived || []).filter(matches);
const byColumn = Object.fromEntries(COLUMNS.map((c) => [c.key, visible.filter((t) => t.status === c.key).sort(byPriority)])); const byColumn = Object.fromEntries(COLUMNS.map((c) => [c.key, visible.filter((t) => t.status === c.key).sort(byPriority)]));
@@ -114,6 +116,12 @@ export default function KanbanBoard() {
if (!drag) return; if (!drag) return;
// Die Karte behält ihre Priorität: einsortiert wird nur zwischen Karten derselben Stufe // Die Karte behält ihre Priorität: einsortiert wird nur zwischen Karten derselben Stufe
const moved = data.tasks.find((t) => t.id === drag.id); const moved = data.tasks.find((t) => t.id === drag.id);
// Gesperrt: bleibt in "Offen", bis die Voraussetzungen erledigt sind
if (moved.blocked && status !== 'offen' && moved.status !== status) {
setDrag(null);
setError(`„${moved.title}“ ist gesperrt – erst erledigen: ${waitingFor(moved, allTasks).join(', ')}`);
return;
}
const cards = byColumn[status].filter((t) => t.id !== drag.id); const cards = byColumn[status].filter((t) => t.id !== drag.id);
const index = drag.index ?? cards.length; const index = drag.index ?? cards.length;
const same = cards.filter((t) => t.priority === moved.priority); const same = cards.filter((t) => t.priority === moved.priority);
@@ -202,6 +210,7 @@ export default function KanbanBoard() {
<TaskCard <TaskCard
task={task} task={task}
users={users} users={users}
waitingFor={waitingFor(task, allTasks)}
dragging={drag?.id === task.id} dragging={drag?.id === task.id}
onOpen={() => open({ task })} onOpen={() => open({ task })}
onDragStart={(e) => { onDragStart={(e) => {
@@ -257,6 +266,7 @@ export default function KanbanBoard() {
ref={dialog} ref={dialog}
target={editing} target={editing}
users={users} users={users}
tasks={allTasks}
me={user} me={user}
onSave={(taskId, body) => run(taskId ? 'PATCH' : 'POST', taskId ? `/api/kanban/aufgaben/${taskId}` : `/api/kanban/projekte/${project.id}/aufgaben`, body)} onSave={(taskId, body) => run(taskId ? 'PATCH' : 'POST', taskId ? `/api/kanban/aufgaben/${taskId}` : `/api/kanban/projekte/${project.id}/aufgaben`, body)}
onDelete={(taskId) => run('DELETE', `/api/kanban/aufgaben/${taskId}`)} onDelete={(taskId) => run('DELETE', `/api/kanban/aufgaben/${taskId}`)}
+12
View File
@@ -201,3 +201,15 @@
.archive-title { flex: 1 1 220px; min-width: 0; text-align: left; font: inherit; font-weight: 600; color: var(--bright); background: none; border: 0; padding: 0; cursor: pointer; text-decoration: line-through; text-decoration-color: var(--muted); } .archive-title { flex: 1 1 220px; min-width: 0; text-align: left; font: inherit; font-weight: 600; color: var(--bright); background: none; border: 0; padding: 0; cursor: pointer; text-decoration: line-through; text-decoration-color: var(--muted); }
.archive-title:hover { color: var(--accent); } .archive-title:hover { color: var(--accent); }
.archive-dates { color: var(--muted); font-size: 0.78rem; } .archive-dates { color: var(--muted); font-size: 0.78rem; }
/* Checkliste und Voraussetzungen */
.task-card.blocked { opacity: 0.75; border-style: dashed; }
.task-blocked { display: block; font-size: 0.78rem; color: var(--warn); }
.task-checks { margin-left: auto; font-size: 0.76rem; color: var(--muted); font-variant-numeric: tabular-nums; }
.task-checks.complete { color: var(--ok); }
.checklist-item { display: flex; align-items: center; gap: 8px; }
.checklist-item .checkbox { flex: 1; min-width: 0; }
.checklist-item .done { text-decoration: line-through; color: var(--muted); }
.checklist-add { display: flex; gap: 8px; margin-top: 8px; }
.checklist-add input { flex: 1; min-width: 0; }
.requires-list { max-height: 180px; overflow-y: auto; }