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). - **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 - **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** 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
+1 -1
View File
@@ -273,7 +273,7 @@ CREATE TABLE IF NOT EXISTS kanban_checklist (
position INTEGER NOT NULL 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 ( CREATE TABLE IF NOT EXISTS kanban_dependencies (
task_id INTEGER NOT NULL REFERENCES kanban_tasks(id) ON DELETE CASCADE, task_id INTEGER NOT NULL REFERENCES kanban_tasks(id) ON DELETE CASCADE,
requires_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_CHECKLIST = 100
MAX_CHECK_TEXT = 200 MAX_CHECK_TEXT = 200
MAX_REQUIRES = 30 MAX_REQUIRES = 30
# Eine Voraussetzung gibt ihre abhängigen Aufgaben frei, sobald sie so weit ist
UNLOCKING = ("wartet", "erledigt")
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
@@ -226,16 +228,16 @@ def _task_extras(conn, project_id: int) -> dict:
): ):
e = extras.setdefault(r["task_id"], {"checklist": [], "requires": [], "blocked": False}) e = extras.setdefault(r["task_id"], {"checklist": [], "requires": [], "blocked": False})
e["requires"].append(r["requires_id"]) 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 return extras
def _open_requirements(conn, task_id: int) -> list: 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( 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 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 != 'erledigt' ORDER BY t.title""", WHERE d.task_id = ? AND t.status NOT IN ({','.join('?' * len(UNLOCKING))}) ORDER BY t.title""",
(task_id,), (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: 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)): 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: 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 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") anna = login("anna")
pid = make_board(anna) pid = make_board(anna)
a = task(anna, pid, "Wand streichen") 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"]] assert b["blocked"] is True and b["requires"] == [a["id"]]
res = anna.patch(f"/api/kanban/aufgaben/{b['id']}", json={"status": "arbeit"}) res = anna.patch(f"/api/kanban/aufgaben/{b['id']}", json={"status": "arbeit"})
assert res.status_code == 400 and "Wand streichen" in res.json()["error"] 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() data = anna.patch(f"/api/kanban/aufgaben/{b['id']}", json={"status": "arbeit"}).json()
assert find(data, "Bilder aufhängen")["blocked"] is False 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 // 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) { export function waitingFor(task, tasks) {
return (task.requires || []) return (task.requires || [])
.map((id) => tasks.find((t) => t.id === id)) .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); .map((t) => t.title);
} }
@@ -43,7 +43,7 @@ export default function TaskCard({ task, users, waitingFor: waiting = [], draggi
</span> </span>
{task.description && <span className="task-desc">{task.description}</span>} {task.description && <span className="task-desc">{task.description}</span>}
{task.blocked && ( {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 aria-hidden="true">🔒</span> wartet auf {waiting.join(', ') || 'Voraussetzungen'}
</span> </span>
)} )}
@@ -1,9 +1,9 @@
import { useState } from 'react'; 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, UNLOCKING } from './board.js';
// Aufgabe anlegen oder bearbeiten: Titel, Beschreibung, Checkliste, Status, Priorität, Bearbeiter, // 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 }) { export default function TaskDialog({ ref, target, users, tasks = [], me, onSave, onDelete }) {
const task = target?.task; const task = target?.task;
@@ -22,7 +22,7 @@ export default function TaskDialog({ ref, target, users, tasks = [], me, onSave,
const others = tasks const others = tasks
.filter((t) => t.id !== task?.id) .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')); .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 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 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 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 // Noch nicht hinzugefügter Checklisten-Text soll nicht verloren gehen
const checklist = newCheck.trim() ? [...form.checklist, { text: newCheck.trim(), done: false }] : form.checklist; const checklist = newCheck.trim() ? [...form.checklist, { text: newCheck.trim(), done: false }] : form.checklist;
if (blocked && form.status !== 'offen' && form.status !== task?.status) { 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; return;
} }
setBusy(true); setBusy(true);
@@ -136,7 +136,7 @@ export default function TaskDialog({ ref, target, users, tasks = [], me, onSave,
</label> </label>
</div> </div>
<fieldset className="link-picker"> <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 ? ( {others.length === 0 ? (
<p className="hint">Keine anderen Aufgaben in diesem Projekt.</p> <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"> <label className="checkbox">
<input type="checkbox" checked={form.requires.includes(t.id)} onChange={() => toggleRequire(t.id)} /> <input type="checkbox" checked={form.requires.includes(t.id)} onChange={() => toggleRequire(t.id)} />
{t.title} {t.title}
<small>{t.status === 'erledigt' ? ' ✓ erledigt' : ''}</small> <small>{t.status === 'erledigt' ? ' ✓ erledigt' : t.status === 'wartet' ? ' ✓ wartet' : ''}</small>
</label> </label>
</li> </li>
))} ))}
</ul> </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> </fieldset>
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}
{task && ( {task && (
+3
View File
@@ -27,3 +27,6 @@ export function positionAt(cards, index) {
if (after == null) return before + 1; if (after == null) return before + 1;
return (before + after) / 2; 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; 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 // Gesperrt: bleibt in "Offen", bis die Voraussetzungen in "Wartet" oder "Erledigt" stehen
if (moved.blocked && status !== 'offen' && moved.status !== status) { if (moved.blocked && status !== 'offen' && moved.status !== status) {
setDrag(null); 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; return;
} }
const cards = byColumn[status].filter((t) => t.id !== drag.id); const cards = byColumn[status].filter((t) => t.id !== drag.id);