Kanban: Voraussetzung gibt schon bei "Wartet" frei

- Eine abhängige Aufgabe bleibt gesperrt (in "Offen"), bis jede
  Voraussetzung in "Wartet" oder "Erledigt" steht – vorher erst bei
  "Erledigt"
- Server (UNLOCKING), Karte, Dialog und Meldungen angepasst; Test prüft
  "In Arbeit" (gesperrt) und "Wartet" (frei)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-15 20:12:59 +02:00
1 parent fef9c8f16e
commit fdbb31065f
8 files changed
+33 -24

No files matched your search

+2 -1
View File
@@ -226,7 +226,8 @@ 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.
davon noch nicht in „Wartet“ oder „Erledigt“ steht, 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
+1 -1
View File
@@ -273,7 +273,7 @@ CREATE TABLE IF NOT EXISTS kanban_checklist (
position INTEGER NOT NULL
);
-- Abhängigkeiten: task_id ist gesperrt, bis requires_id erledigt ist (nur innerhalb eines Projekts)
-- Abhängigkeiten: task_id ist gesperrt, bis requires_id in "Wartet" oder "Erledigt" steht (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,
+9 -7
View File
@@ -28,6 +28,8 @@ MAX_LINKS = 20
MAX_CHECKLIST = 100
MAX_CHECK_TEXT = 200
MAX_REQUIRES = 30
# Eine Voraussetzung gibt ihre abhängigen Aufgaben frei, sobald sie so weit ist
UNLOCKING = ("wartet", "erledigt")
ARCHIVE_AFTER = timedelta(days=2)
DELETE_AFTER = timedelta(days=14)
CLEANUP_SECONDS = 3600
@@ -226,16 +228,16 @@ def _task_extras(conn, project_id: int) -> dict:
):
e = extras.setdefault(r["task_id"], {"checklist": [], "requires": [], "blocked": False})
e["requires"].append(r["requires_id"])
e["blocked"] = e["blocked"] or r["status"] != "erledigt"
e["blocked"] = e["blocked"] or r["status"] not in UNLOCKING
return extras
def _open_requirements(conn, task_id: int) -> list:
"""Titel der noch nicht erledigten Voraussetzungen einer Aufgabe."""
"""Titel der Voraussetzungen, die noch nicht in "Wartet" oder "Erledigt" stehen."""
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,),
f"""SELECT t.title FROM kanban_dependencies d JOIN kanban_tasks t ON t.id = d.requires_id
WHERE d.task_id = ? AND t.status NOT IN ({','.join('?' * len(UNLOCKING))}) ORDER BY t.title""",
(task_id, *UNLOCKING),
)]
@@ -291,9 +293,9 @@ def _save_requires(conn, task_id: int, ids: list) -> None:
def _ensure_unlocked(conn, task_id: int, status: str) -> None:
"""Gesperrte Aufgaben bleiben in "Offen", bis alle Voraussetzungen erledigt sind."""
"""Gesperrte Aufgaben bleiben in "Offen", bis alle Voraussetzungen in "Wartet" oder "Erledigt" stehen."""
if status != "offen" and (missing := _open_requirements(conn, task_id)):
raise ValidationError("Gesperrt – erst erledigen: " + ", ".join(f"„{t}“" for t in missing))
raise ValidationError("Gesperrt – erst auf „Wartet“ oder „Erledigt“ setzen: " + ", ".join(f"„{t}“" for t in missing))
def _board(conn, project_id: int) -> dict:
+5 -2
View File
@@ -85,7 +85,7 @@ def test_checkliste_speichern_und_abhaken(login):
assert anna.patch(f"/api/kanban/aufgaben/{t['id']}", json={"checklist": [{"text": " "}]}).status_code == 400
def test_voraussetzung_sperrt_bis_erledigt(login):
def test_voraussetzung_sperrt_bis_wartet_oder_erledigt(login):
anna = login("anna")
pid = make_board(anna)
a = task(anna, pid, "Wand streichen")
@@ -94,7 +94,10 @@ def test_voraussetzung_sperrt_bis_erledigt(login):
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"})
# "In Arbeit" reicht noch nicht, "Wartet" gibt frei
anna.patch(f"/api/kanban/aufgaben/{a['id']}", json={"status": "arbeit"})
assert find(board(anna, pid), "Bilder aufhängen")["blocked"] is True
anna.patch(f"/api/kanban/aufgaben/{a['id']}", json={"status": "wartet"})
data = anna.patch(f"/api/kanban/aufgaben/{b['id']}", json={"status": "arbeit"}).json()
assert find(data, "Bilder aufhängen")["blocked"] is False
+4 -4
View File
@@ -1,4 +1,4 @@
import { initials, PRIORITIES } from './board.js';
import { initials, PRIORITIES, UNLOCKING } from './board.js';
// Karte einer Aufgabe im Board (ziehbar) und Anzeige des Bearbeiters
@@ -13,11 +13,11 @@ export function Assignee({ id, users }) {
);
}
// Titel der noch offenen Voraussetzungen einer Aufgabe
// Titel der Voraussetzungen, die noch nicht in "Wartet" oder "Erledigt" stehen
export function waitingFor(task, tasks) {
return (task.requires || [])
.map((id) => tasks.find((t) => t.id === id))
.filter((t) => t && t.status !== 'erledigt')
.filter((t) => t && !UNLOCKING.includes(t.status))
.map((t) => t.title);
}
@@ -43,7 +43,7 @@ export default function TaskCard({ task, users, waitingFor: waiting = [], draggi
</span>
{task.description && <span className="task-desc">{task.description}</span>}
{task.blocked && (
<span className="task-blocked" title="Gesperrt, bis die Voraussetzungen erledigt sind">
<span className="task-blocked" title="Gesperrt, bis die Voraussetzungen in „Wartet“ oder „Erledigt“ stehen">
<span aria-hidden="true">🔒</span> wartet auf {waiting.join(', ') || 'Voraussetzungen'}
</span>
)}
@@ -1,9 +1,9 @@
import { useState } from 'react';
import { fmtDate, personName } from '../../api.js';
import { COLUMNS, PRIORITIES } from './board.js';
import { COLUMNS, PRIORITIES, UNLOCKING } from './board.js';
// Aufgabe anlegen oder bearbeiten: Titel, Beschreibung, Checkliste, Status, Priorität, Bearbeiter,
// Voraussetzungen (gesperrt, bis diese erledigt sind); löschen
// Voraussetzungen (gesperrt, bis diese in "Wartet" oder "Erledigt" stehen); löschen
export default function TaskDialog({ ref, target, users, tasks = [], me, onSave, onDelete }) {
const task = target?.task;
@@ -22,7 +22,7 @@ export default function TaskDialog({ ref, target, users, tasks = [], me, onSave,
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 missing = form.requires.map((id) => tasks.find((t) => t.id === id)).filter((t) => t && !UNLOCKING.includes(t.status));
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)) }));
@@ -42,7 +42,7 @@ export default function TaskDialog({ ref, target, users, tasks = [], me, onSave,
// 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(', ')}`);
setError(`Gesperrt – erst auf „Wartet“ oder „Erledigt“ setzen: ${missing.map((t) => `„${t.title}“`).join(', ')}`);
return;
}
setBusy(true);
@@ -136,7 +136,7 @@ export default function TaskDialog({ ref, target, users, tasks = [], me, onSave,
</label>
</div>
<fieldset className="link-picker">
<legend>Setzt voraus <small>– gesperrt, bis diese Aufgaben erledigt sind</small></legend>
<legend>Setzt voraus <small>– gesperrt, bis diese Aufgaben in „Wartet“ oder „Erledigt“ stehen</small></legend>
{others.length === 0 ? (
<p className="hint">Keine anderen Aufgaben in diesem Projekt.</p>
) : (
@@ -146,13 +146,13 @@ export default function TaskDialog({ ref, target, users, tasks = [], me, onSave,
<label className="checkbox">
<input type="checkbox" checked={form.requires.includes(t.id)} onChange={() => toggleRequire(t.id)} />
{t.title}
<small>{t.status === 'erledigt' ? ' ✓ erledigt' : ''}</small>
<small>{t.status === 'erledigt' ? ' ✓ erledigt' : t.status === 'wartet' ? ' ✓ wartet' : ''}</small>
</label>
</li>
))}
</ul>
)}
{blocked && <p className="hint task-blocked">🔒 Gesperrt, bis erledigt: {missing.map((t) => t.title).join(', ')}</p>}
{blocked && <p className="hint task-blocked">🔒 Gesperrt, bis in „Wartet“ oder „Erledigt“: {missing.map((t) => t.title).join(', ')}</p>}
</fieldset>
{error && <p className="error">{error}</p>}
{task && (
+3
View File
@@ -27,3 +27,6 @@ export function positionAt(cards, index) {
if (after == null) return before + 1;
return (before + after) / 2;
}
// Eine Voraussetzung gibt ihre abhängigen Aufgaben frei, sobald sie in einer dieser Spalten steht
export const UNLOCKING = ['wartet', 'erledigt'];
+2 -2
View File
@@ -116,10 +116,10 @@ export default function KanbanBoard() {
if (!drag) return;
// Die Karte behält ihre Priorität: einsortiert wird nur zwischen Karten derselben Stufe
const moved = data.tasks.find((t) => t.id === drag.id);
// Gesperrt: bleibt in "Offen", bis die Voraussetzungen erledigt sind
// Gesperrt: bleibt in "Offen", bis die Voraussetzungen in "Wartet" oder "Erledigt" stehen
if (moved.blocked && status !== 'offen' && moved.status !== status) {
setDrag(null);
setError(`„${moved.title}“ ist gesperrt – erst erledigen: ${waitingFor(moved, allTasks).join(', ')}`);
setError(`„${moved.title}“ ist gesperrt – erst auf „Wartet“ oder „Erledigt“ setzen: ${waitingFor(moved, allTasks).join(', ')}`);
return;
}
const cards = byColumn[status].filter((t) => t.id !== drag.id);