Files
p42.f-w/frontend/src/pages/KanbanBoard.jsx
T
guyg1474andClaude Opus 5.5 dd58b3d1f8 P42 Dashboard: erster Stand
FastAPI-Backend und React-Frontend mit Konten, Sendungsverfolgung,
Projekt-Artikeln, Vorrat & Einkauf mit Rezeptabgleich, Reise-Weltkarte,
Kanban-Projekten mit Priorität, Server-Monitoring und den Designs
Matrix und Blackwall. Betrieb per Docker hinter nginx (siehe DEPLOY.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:08:11 +02:00

412 lines
16 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useRef, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { api, fmtDate, personName } from '../api.js';
import { useAuth } from '../auth.jsx';
import KanbanProjectDialog from '../components/KanbanProjectDialog.jsx';
// Board eines Kanban-Projekts. Gemeinsam: regelmäßig nachladen, damit Änderungen der anderen ankommen
const REFRESH_SECONDS = 15;
const FILTER_KEY = 'p42.kanbanFilter';
export const PRIORITIES = [
{ key: 'hoch', title: 'Hoch', rank: 1 },
{ key: 'mittel', title: 'Mittel', rank: 2 },
{ key: 'niedrig', title: 'Niedrig', rank: 3 },
];
const RANK = Object.fromEntries(PRIORITIES.map((p) => [p.key, p.rank]));
// Innerhalb einer Spalte: zuerst nach Priorität, dann eigene Reihenfolge
const byPriority = (a, b) => (RANK[a.priority] ?? 2) - (RANK[b.priority] ?? 2) || a.position - b.position;
export const COLUMNS = [
{ key: 'offen', title: 'Offen' },
{ key: 'arbeit', title: 'In Arbeit' },
{ key: 'wartet', title: 'Wartet' },
{ key: 'erledigt', title: 'Erledigt' },
];
const initials = (name) => name.split(/\s+/).map((w) => w[0]).join('').slice(0, 2).toUpperCase();
function loadFilter() {
try { return localStorage.getItem(FILTER_KEY) || 'alle'; } catch { return 'alle'; }
}
// Position zwischen den Nachbarn an der Einfügestelle
function positionAt(cards, index) {
const before = cards[index - 1]?.position;
const after = cards[index]?.position;
if (before == null && after == null) return 1;
if (before == null) return after - 1;
if (after == null) return before + 1;
return (before + after) / 2;
}
export default function KanbanBoard() {
const { id } = useParams();
const navigate = useNavigate();
const { user } = useAuth();
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [filter, setFilter] = useState(loadFilter);
const [editing, setEditing] = useState(null); // null | { task } | { status } (neu)
const [drag, setDrag] = useState(null); // { id, status, index } während Drag & Drop
const dialog = useRef(null);
const projectDialog = useRef(null);
const [projectForm, setProjectForm] = useState(null);
const load = useCallback(async () => {
try {
setData(await api('GET', `/api/kanban/projekte/${encodeURIComponent(id)}`));
} catch (e) {
setError(e.message);
}
}, [id]);
useEffect(() => {
if (data?.project) document.title = `${data.project.name} – Kanban`;
return () => { document.title = 'P42 Dashboard'; };
}, [data?.project?.name]);
useEffect(() => {
load();
const timer = setInterval(() => !document.hidden && load(), REFRESH_SECONDS * 1000);
const onVisible = () => !document.hidden && load();
document.addEventListener('visibilitychange', onVisible);
return () => {
clearInterval(timer);
document.removeEventListener('visibilitychange', onVisible);
};
}, [load]);
async function run(method, url, body) {
setError(null);
try {
setData(await api(method, url, body));
return true;
} catch (e) {
setError(e.message);
load();
return false;
}
}
function chooseFilter(value) {
setFilter(value);
try { localStorage.setItem(FILTER_KEY, value); } catch { /* ignorieren */ }
}
// Erst rendern (Formular ist dann schon befüllt), dann öffnen – sonst ist das Feld kurz leer
function open(target) {
setEditing({ ...target, opened: Date.now() });
}
useEffect(() => {
if (editing) dialog.current?.showModal();
}, [editing]);
const users = data?.users || [];
const project = data?.project;
function editProject() {
setProjectForm({ name: project.name, description: project.description, links: project.links.map((l) => l.id), opened: Date.now() });
}
useEffect(() => {
if (projectForm) projectDialog.current?.showModal();
}, [projectForm]);
async function saveProject(body) {
try {
setData(await api('PATCH', `/api/kanban/projekte/${project.id}`, body));
return null;
} catch (e) {
return e.message;
}
}
const mayDelete = user.isAdmin || project?.createdBy?.id === user.id;
async function setCompleted(completed) {
await saveProject({ completed }).then((err) => err && setError(err));
}
async function deleteProject() {
const n = data.tasks.length;
if (!window.confirm(`Kanban-Projekt „${project.name}“${n ? ` mit ${n} ${n === 1 ? 'Aufgabe' : 'Aufgaben'}` : ''} endgültig löschen?`)) return;
try {
await api('DELETE', `/api/kanban/projekte/${project.id}`);
navigate('/kanban');
} catch (e) {
setError(e.message);
}
}
if (!data) {
return <><BackLink />{error ? <p className="error">{error}</p> : <p className="hint">Lade …</p>}</>;
}
const visible = (data?.tasks || []).filter((t) =>
filter === 'alle' ? true
: filter === 'meine' ? t.assignee === user.id
: filter === 'niemand' ? t.assignee == null
: t.assignee === Number(filter));
const byColumn = Object.fromEntries(COLUMNS.map((c) => [c.key, visible.filter((t) => t.status === c.key).sort(byPriority)]));
// ---------- Drag & Drop (Desktop) ----------
function onDragOver(e, status) {
if (!drag) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const cards = [...e.currentTarget.querySelectorAll('.task-card:not(.dragging)')];
let index = cards.findIndex((el) => {
const r = el.getBoundingClientRect();
return e.clientY < r.top + r.height / 2;
});
if (index < 0) index = cards.length;
if (drag.status !== status || drag.index !== index) setDrag((d) => ({ ...d, status, index }));
}
function onDrop(e, status) {
e.preventDefault();
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);
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);
const sameIndex = cards.slice(0, index).filter((t) => t.priority === moved.priority).length;
const position = positionAt(same, sameIndex);
const id = drag.id;
setDrag(null);
// Sofort anzeigen, der Server bestätigt mit dem neuen Stand
setData((d) => ({ ...d, tasks: d.tasks.map((t) => (t.id === id ? { ...t, status, position } : t)).sort((a, b) => a.position - b.position) }));
run('PATCH', `/api/kanban/aufgaben/${id}`, { status, position });
}
return (
<>
<BackLink />
{project.completedAt && (
<div className="notice board-completed">
Abgeschlossen am {fmtDate(project.completedAt)}. Aufgaben lassen sich weiter bearbeiten.
</div>
)}
<div className="page-head board-page-head">
<div>
<h1>{project.name}</h1>
{project.description && <p className="lead board-description">{project.description}</p>}
{project.links.length > 0 && (
<ul className="chip-list board-links" aria-label="Verknüpfte Artikel">
{project.links.map((a) => (
<li key={a.id}><Link className="chip" to={`/projekte/${a.id}`}>▤ {a.title}</Link></li>
))}
</ul>
)}
</div>
<div className="page-actions">
<button className="btn ghost" type="button" onClick={editProject}>Bearbeiten</button>
<button className="btn ghost" type="button" onClick={() => setCompleted(!project.completedAt)}>
{project.completedAt ? 'Wieder öffnen' : 'Abschließen'}
</button>
{mayDelete && <button className="btn ghost danger-btn" type="button" onClick={deleteProject}>Löschen</button>}
<label className="kanban-filter">
<span>Bearbeiter</span>
<select value={filter} onChange={(e) => chooseFilter(e.target.value)}>
<option value="alle">Alle</option>
<option value="meine">Mir zugeteilt</option>
<option value="niemand">Niemandem zugeteilt</option>
{users.filter((u) => u.id !== user.id).map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
</label>
<button className="btn" type="button" onClick={() => open({ status: 'offen' })}>+ Neue Aufgabe</button>
</div>
</div>
{error && <p className="error kanban-error" role="alert">{error}</p>}
<div className="board">
{COLUMNS.map((col) => {
const cards = byColumn[col.key];
const target = drag?.status === col.key;
return (
<section
key={col.key}
className={`board-column ${col.key}${target ? ' drop-target' : ''}`}
aria-labelledby={`col-${col.key}`}
onDragOver={(e) => onDragOver(e, col.key)}
onDrop={(e) => onDrop(e, col.key)}
>
<header className="board-head">
<h2 id={`col-${col.key}`}>{col.title} <span className="count">{cards.length}</span></h2>
<button className="link-btn" type="button" onClick={() => open({ status: col.key })} aria-label={`Neue Aufgabe in ${col.title}`}>+</button>
</header>
<ul className="board-cards">
{cards.map((task) => {
// Gezogene Karte bleibt im DOM (sonst bricht der Browser das Ziehen ab), zählt aber nicht mit
const index = cards.filter((t) => t.id !== drag?.id).indexOf(task);
return (
<li key={task.id}>
{target && index >= 0 && drag.index === index && <div className="drop-line" aria-hidden="true" />}
<TaskCard
task={task}
users={users}
dragging={drag?.id === task.id}
onOpen={() => open({ task })}
onDragStart={(e) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(task.id));
// Erst im nächsten Frame markieren, sonst fehlt die Karte im Drag-Bild
requestAnimationFrame(() => setDrag({ id: task.id, status: null, index: null }));
}}
onDragEnd={() => setDrag(null)}
/>
</li>
);
})}
{target && drag.index >= cards.filter((t) => t.id !== drag.id).length && <li className="drop-line" aria-hidden="true" />}
{cards.length === 0 && !target && <li className="board-empty">Keine Aufgaben</li>}
</ul>
</section>
);
})}
</div>
<TaskDialog
key={`aufgabe-${editing?.opened ?? 'zu'}`}
ref={dialog}
target={editing}
users={users}
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}`)}
/>
<KanbanProjectDialog
key={`projekt-${projectForm?.opened ?? 'zu'}`}
ref={projectDialog}
initial={projectForm}
articles={data.articles}
onSave={saveProject}
/>
</>
);
}
function BackLink() {
return <Link className="back" to="/kanban">← Alle Projekte</Link>;
}
function Assignee({ id, users }) {
const person = users.find((u) => u.id === id);
if (!person) return <span className="assignee none">Niemand</span>;
return (
<span className="assignee">
<span className="avatar" aria-hidden="true">{initials(person.name)}</span>
{person.name}
</span>
);
}
function TaskCard({ task, users, dragging, onOpen, onDragStart, onDragEnd }) {
return (
<button
type="button"
className={`task-card prio-card-${task.priority}${dragging ? ' dragging' : ''}`}
draggable
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onClick={onOpen}
>
<span className="task-top">
<strong className="task-title">{task.title}</strong>
{task.priority !== 'mittel' && (
<span className={`prio prio-${task.priority}`} title={`Priorität: ${PRIORITIES.find((p) => p.key === task.priority)?.title}`}>
{PRIORITIES.find((p) => p.key === task.priority)?.title}
</span>
)}
</span>
{task.description && <span className="task-desc">{task.description}</span>}
<span className="task-meta">
<Assignee id={task.assignee} users={users} />
</span>
</button>
);
}
function TaskDialog({ ref, target, users, 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' }));
const [busy, setBusy] = useState(false);
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();
setBusy(true);
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,
});
setBusy(false);
if (ok) ref.current.close();
}
async function remove() {
if (!window.confirm(`Aufgabe „${task.title}“ löschen?`)) return;
setBusy(true);
const ok = await onDelete(task.id);
setBusy(false);
if (ok) ref.current.close();
}
return (
<dialog className="modal task-dialog" ref={ref}>
<form className="form" onSubmit={submit}>
<h2>{task ? 'Aufgabe bearbeiten' : 'Neue Aufgabe'}</h2>
<label>
Titel
<input {...field('title')} maxLength={120} required autoFocus />
</label>
<label>
Beschreibung
<textarea {...field('description')} rows={6} maxLength={5000} placeholder="Was ist zu tun? Details, Links, Notizen …" />
</label>
<div className="task-dialog-row">
<label>
Status
<select {...field('status')}>
{COLUMNS.map((c) => <option key={c.key} value={c.key}>{c.title}</option>)}
</select>
</label>
<label>
Priorität
<select {...field('priority')}>
{PRIORITIES.map((p) => <option key={p.key} value={p.key}>{p.title}</option>)}
</select>
</label>
<label>
Bearbeiter
<select {...field('assignee')}>
<option value="">Niemand</option>
{users.map((u) => <option key={u.id} value={u.id}>{u.name}{u.id === me.id ? ' (ich)' : ''}</option>)}
</select>
</label>
</div>
{task && (
<p className="hint task-history">
Angelegt von {personName(task.createdBy)} am {fmtDate(task.createdAt)}
{task.updatedAt !== task.createdAt && <> · zuletzt geändert von {personName(task.updatedBy)} am {fmtDate(task.updatedAt)}</>}
</p>
)}
<div className="form-actions">
{canDelete && <button className="link-btn danger task-delete" type="button" disabled={busy} onClick={remove}>Löschen</button>}
<button className="btn ghost" type="button" onClick={() => ref.current.close()}>Abbrechen</button>
<button className="btn" type="submit" disabled={busy}>{task ? 'Speichern' : 'Anlegen'}</button>
</div>
</form>
</dialog>
);
}