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 <>{error ?

{error}

:

Lade …

}; } 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 ( <> {project.completedAt && (
Abgeschlossen am {fmtDate(project.completedAt)}. Aufgaben lassen sich weiter bearbeiten.
)}

{project.name}

{project.description &&

{project.description}

} {project.links.length > 0 && (
    {project.links.map((a) => (
  • ▤ {a.title}
  • ))}
)}
{mayDelete && }
{error &&

{error}

}
{COLUMNS.map((col) => { const cards = byColumn[col.key]; const target = drag?.status === col.key; return (
onDragOver(e, col.key)} onDrop={(e) => onDrop(e, col.key)} >

{col.title} {cards.length}

    {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 (
  • {target && index >= 0 && drag.index === index &&
  • ); })} {target && drag.index >= cards.filter((t) => t.id !== drag.id).length &&
  • Keine Aufgaben
  • }
); })}
run(taskId ? 'PATCH' : 'POST', taskId ? `/api/kanban/aufgaben/${taskId}` : `/api/kanban/projekte/${project.id}/aufgaben`, body)} onDelete={(taskId) => run('DELETE', `/api/kanban/aufgaben/${taskId}`)} /> ); } function BackLink() { return ← Alle Projekte; } function Assignee({ id, users }) { const person = users.find((u) => u.id === id); if (!person) return Niemand; return ( {person.name} ); } function TaskCard({ task, users, dragging, onOpen, onDragStart, onDragEnd }) { return ( ); } 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 (

{task ? 'Aufgabe bearbeiten' : 'Neue Aufgabe'}