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}
: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.description}
} {project.links.length > 0 && ({error}
}