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:
1 parent
fef9c8f16e
commit
fdbb31065f
8 files changed
+33
-24
No files matched your search
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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'];
|
||||||
@@ -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);
|
||||||
|
|||||||
Reference in new issue
Block a user