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)}
|
||||
|
||||
Reference in new issue
Block a user