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>
This commit is contained in:
commit
dd58b3d1f8
75 files changed
+11418
No files matched your search
@@ -0,0 +1,411 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user