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

+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 (
<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
onDragStart={onDragStart}
onDragEnd={onDragEnd}
@@ -32,8 +42,18 @@ export default function TaskCard({ task, users, dragging, onOpen, onDragStart, o
)}
</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">
<Assignee id={task.assignee} users={users} />
{total > 0 && (
<span className={`task-checks${done === total ? ' complete' : ''}`} title="Checkliste">
☑ {done}/{total}
</span>
)}
</span>
</button>
);
+90 -5
View File
@@ -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
<textarea {...field('description')} rows={6} maxLength={5000} placeholder="Was ist zu tun? Details, Links, Notizen …" />
</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">
<label>
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>
</label>
<label>
@@ -70,6 +135,26 @@ export default function TaskDialog({ ref, target, users, me, onSave, onDelete })
</select>
</label>
</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 && (
<p className="hint task-history">
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 { byPriority, COLUMNS, positionAt } from '../components/kanban/board.js';
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 useLiveData from '../useLiveData.js';
@@ -92,6 +92,8 @@ export default function KanbanBoard() {
: filter === 'niemand' ? t.assignee == null
: t.assignee === Number(filter));
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 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;
// 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
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 index = drag.index ?? cards.length;
const same = cards.filter((t) => t.priority === moved.priority);
@@ -202,6 +210,7 @@ export default function KanbanBoard() {
<TaskCard
task={task}
users={users}
waitingFor={waitingFor(task, allTasks)}
dragging={drag?.id === task.id}
onOpen={() => open({ task })}
onDragStart={(e) => {
@@ -257,6 +266,7 @@ export default function KanbanBoard() {
ref={dialog}
target={editing}
users={users}
tasks={allTasks}
me={user}
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}`)}
+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:hover { color: var(--accent); }
.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; }