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:
1 parent
a1a1998d1e
commit
9df0e38f52
10 files changed
+375
-28
No files matched your search
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}`)}
|
||||
|
||||
@@ -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; }
|
||||
Reference in new issue
Block a user