From 9df0e38f5280affd5e959d68ce010115e3417639 Mon Sep 17 00:00:00 2001 From: guyg1474 <340572510+guyg1474@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:10:10 +0200 Subject: [PATCH] Kanban: Checklisten und Voraussetzungen zwischen Aufgaben MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 4 + backend/app/db.py | 17 +++ backend/app/kanban.py | 120 +++++++++++++++++- backend/app/migrations.py | 43 ++++--- backend/tests/test_kanban.py | 62 +++++++++ backend/tests/test_migrationen.py | 14 ++ frontend/src/components/kanban/TaskCard.jsx | 24 +++- frontend/src/components/kanban/TaskDialog.jsx | 95 +++++++++++++- frontend/src/pages/KanbanBoard.jsx | 12 +- frontend/src/styles/kanban.css | 12 ++ 10 files changed, 375 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index e569283..b6987be 100644 --- a/README.md +++ b/README.md @@ -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 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** (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. diff --git a/backend/app/db.py b/backend/app/db.py index 600e636..57ed180 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -204,6 +204,23 @@ CREATE TABLE IF NOT EXISTS kanban_project_links ( 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 ( key TEXT PRIMARY KEY, value TEXT NOT NULL diff --git a/backend/app/kanban.py b/backend/app/kanban.py index bd3189f..9bb54a2 100644 --- a/backend/app/kanban.py +++ b/backend/app/kanban.py @@ -25,6 +25,9 @@ MAX_DESCRIPTION = 5000 MAX_TASKS = 1000 # pro Projekt MAX_PROJECTS = 300 MAX_LINKS = 20 +MAX_CHECKLIST = 100 +MAX_CHECK_TEXT = 200 +MAX_REQUIRES = 30 ARCHIVE_AFTER = timedelta(days=2) DELETE_AFTER = timedelta(days=14) CLEANUP_SECONDS = 3600 @@ -194,6 +197,9 @@ def _task(row) -> dict: "priority": PRIORITY_NAMES[row["priority"]], "position": row["position"], "doneAt": row["done_at"], + "checklist": [], + "requires": [], + "blocked": False, "createdAt": row["created_at"], "createdBy": _person(row["created_by"], row["created_by_name"]), "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: projects = _projects(conn, project_id) if not projects: @@ -223,11 +318,14 @@ def _board(conn, project_id: int) -> dict: reverse=True, ) archived_ids = {r["id"] for r in archived} + extras = _task_extras(conn, project_id) return { "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 - "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), "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")) status = _status(data.get("status", "offen")) priority = _priority(data.get("priority", "mittel")) + checklist = _checklist(data.get("checklist", [])) with transaction() as conn: if not conn.execute("SELECT 1 FROM kanban_projects WHERE id = ?", (project_id,)).fetchone(): 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: raise ValidationError(f"Maximal {MAX_TASKS} Aufgaben pro Projekt. Bitte erledigte Aufgaben löschen.") 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, _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) @@ -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"]: # 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"])) - 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"]) 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)) diff --git a/backend/app/migrations.py b/backend/app/migrations.py index e0b4b1b..9617cd5 100644 --- a/backend/app/migrations.py +++ b/backend/app/migrations.py @@ -32,6 +32,20 @@ def upgrade(conn) -> None: 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 ---------- # 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. @@ -71,14 +85,12 @@ def _household(conn) -> None: 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_einkauf") - conn.execute("ALTER TABLE household_items RENAME TO household_items_alt") - conn.execute("CREATE TABLE household_items " + HOUSEHOLD_TABLE) - conn.execute( - f"""INSERT INTO household_items (id, key, name, list, list_id, quantity, unit, updated_at, updated_by) - SELECT id, key, name, list, {list_id}, quantity, {unit}, updated_at, updated_by FROM household_items_alt""", + _rebuild( + conn, "household_items", HOUSEHOLD_TABLE, + "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", (default,), ) - conn.execute("DROP TABLE household_items_alt") 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)")} if "project_id" in columns: return - conn.execute("ALTER TABLE kanban_tasks RENAME TO kanban_tasks_alt") - conn.execute("CREATE TABLE kanban_tasks " + KANBAN_TASKS_TABLE) - if conn.execute("SELECT COUNT(*) FROM kanban_tasks_alt").fetchone()[0]: + project = None + if conn.execute("SELECT COUNT(*) FROM kanban_tasks").fetchone()[0]: now = now_iso() project = conn.execute( "INSERT INTO kanban_projects (name, position, created_at, updated_at) VALUES ('Allgemeines Board', 1, ?, ?)", (now, now), ).lastrowid - conn.execute( - """INSERT INTO kanban_tasks (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 - FROM kanban_tasks_alt""", - (project,), - ) - conn.execute("DROP TABLE kanban_tasks_alt") + _rebuild( + conn, "kanban_tasks", KANBAN_TASKS_TABLE, + "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 FROM kanban_tasks", + (project,), + ) # ---------- Schritt 2: Datenschutz ---------- diff --git a/backend/tests/test_kanban.py b/backend/tests/test_kanban.py index 6a388fd..82f7ad3 100644 --- a/backend/tests/test_kanban.py +++ b/backend/tests/test_kanban.py @@ -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 anna.delete(f"/api/kanban/aufgaben/{t1['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 diff --git a/backend/tests/test_migrationen.py b/backend/tests/test_migrationen.py index 0e9730b..dc1fbaf 100644 --- a/backend/tests/test_migrationen.py +++ b/backend/tests/test_migrationen.py @@ -158,3 +158,17 @@ def test_schritt_laeuft_nur_einmal(old_db, monkeypatch): db.init() assert calls == [1] 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 diff --git a/frontend/src/components/kanban/TaskCard.jsx b/frontend/src/components/kanban/TaskCard.jsx index 4c0fdf1..a0cdc14 100644 --- a/frontend/src/components/kanban/TaskCard.jsx +++ b/frontend/src/components/kanban/TaskCard.jsx @@ -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 ( ); diff --git a/frontend/src/components/kanban/TaskDialog.jsx b/frontend/src/components/kanban/TaskDialog.jsx index a3ca9db..e3e5d71 100644 --- a/frontend/src/components/kanban/TaskDialog.jsx +++ b/frontend/src/components/kanban/TaskDialog.jsx @@ -2,28 +2,59 @@ import { useState } from 'react'; import { fmtDate, personName } from '../../api.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; // Wird pro Öffnen neu erzeugt (key), daher direkt aus den Daten vorbelegen 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 [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 canDelete = task && (me.isAdmin || task.createdBy?.id === me.id); async function submit(e) { 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); + setError(null); const ok = await onSave(task?.id, { title: form.title, description: form.description, status: form.status, assignee: form.assignee === '' ? null : Number(form.assignee), priority: form.priority, + checklist: checklist.map(({ text, done }) => ({ text, done })), + requires: form.requires, }); setBusy(false); if (ok) ref.current.close(); @@ -49,11 +80,45 @@ export default function TaskDialog({ ref, target, users, me, onSave, onDelete }) Beschreibung