import { useState, useEffect, useRef, useCallback } from 'react';
import { api, S } from '../lib.js';
// ── Konstanten ────────────────────────────────────────────────────────────────
const PRIORITIES = [
{ id: 'none', label: 'Keine', color: '#666666' },
{ id: 'low', label: 'Niedrig', color: '#4ecdc4' },
{ id: 'medium', label: 'Mittel', color: '#ffe66d' },
{ id: 'high', label: 'Hoch', color: '#ff6b9d' },
];
const COL_COLORS = ['#4ecdc4','#60a5fa','#a78bfa','#ff6b9d','#ffe66d','#fb923c','#4ade80','#f472b6','#94a3b8'];
const fmtCardDate = s => {
if (!s) return '';
const d = new Date(String(s).replace(' ', 'T'));
if (isNaN(d)) return '';
return d.toLocaleDateString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric' })
+ ' ' + d.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' });
};
const prioOf = id => PRIORITIES.find(p => p.id === id) || PRIORITIES[0];
// ── Karten-Modal ──────────────────────────────────────────────────────────────
function CardModal({ card, columnId, columns, onSave, onClose, toast }) {
const [title, setTitle] = useState(card?.title || '');
const [desc, setDesc] = useState(card?.description || '');
const [prio, setPrio] = useState(card?.priority || 'none');
const [moveToCol, setMoveToCol] = useState(card?.column_id || columnId);
const [newColName, setNewColName] = useState('');
const [showNewCol, setShowNewCol] = useState(false);
const [saving, setSaving] = useState(false);
const titleRef = useRef(null);
const newColRef = useRef(null);
const isMob = window.innerWidth < 768;
useEffect(() => { titleRef.current?.focus(); }, []);
useEffect(() => { if (showNewCol) newColRef.current?.focus(); }, [showNewCol]);
const save = async () => {
if (!title.trim()) return;
setSaving(true);
try {
// Neue Spalte anlegen wenn gewünscht
let targetCol = moveToCol;
if (showNewCol && newColName.trim()) {
const nc = await api('/tools/kanban/columns', { body: { title: newColName.trim() } });
targetCol = nc.id;
}
if (card) {
await api(`/tools/kanban/cards/${card.id}`, {
method: 'PATCH',
body: { title, description: desc, priority: prio },
});
// Spalte gewechselt?
if (targetCol && targetCol !== card.column_id) {
await api(`/tools/kanban/cards/${card.id}/move`, { body: { column_id: targetCol } });
}
} else {
await api('/tools/kanban/cards', { body: { column_id: targetCol || columnId, title, description: desc, priority: prio } });
}
onSave();
} catch (e) { toast(e.message, 'error'); }
finally { setSaving(false); }
};
const onKey = e => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) save();
if (e.key === 'Escape') onClose();
};
return (
e.target === e.currentTarget && onClose()}
style={{ position:'fixed',inset:0,background:'rgba(0,0,0,0.7)',zIndex:1000,
display:'flex',
alignItems:isMob?'flex-end':'center',
justifyContent:'center',
padding:isMob?0:20,
// Auf Mobile: unten Platz für die fixe Bottom-Nav (56px + safe-area) lassen
paddingBottom: isMob ? 'calc(56px + env(safe-area-inset-bottom, 0px))' : 20,
}}>
{/* Drag-Handle + Header – immer sichtbar, nicht scrollend */}
{isMob &&
}
{card ? 'KARTE BEARBEITEN' : 'NEUE KARTE'}
{/* Scrollbarer Inhalt */}
{/* Aktionsbuttons – immer sichtbar am unteren Rand, nicht scrollend */}
);
}
// ── Spalten-Header ────────────────────────────────────────────────────────────
function ColumnHeader({ col, onUpdate, onDelete, toast }) {
const [editTitle, setEditTitle] = useState(false);
const [titleVal, setTitleVal] = useState(col.title);
const [showColor, setShowColor] = useState(false);
const inputRef = useRef(null);
useEffect(() => { if (editTitle) inputRef.current?.focus(); }, [editTitle]);
const commitTitle = async () => {
setEditTitle(false);
if (titleVal.trim() && titleVal.trim() !== col.title) {
try { await onUpdate(col.id, { title: titleVal.trim() }); }
catch (e) { toast(e.message, 'error'); setTitleVal(col.title); }
} else { setTitleVal(col.title); }
};
const setColor = async (color) => {
setShowColor(false);
try { await onUpdate(col.id, { color }); }
catch (e) { toast(e.message, 'error'); }
};
return (
{/* Farbstreifen oben */}
{/* Farbpicker-Button */}
{/* Titel */}
{editTitle ? (
setTitleVal(e.target.value)}
onBlur={commitTitle}
onKeyDown={e=>{ if(e.key==='Enter') commitTitle(); if(e.key==='Escape'){setTitleVal(col.title);setEditTitle(false);} }}
style={{ ...S.inp, fontSize:12,fontWeight:700,padding:'3px 7px',flex:1 }} />
) : (
setEditTitle(true)} title="Doppelklick zum Umbenennen"
style={{ flex:1,fontSize:12,fontWeight:700,fontFamily:'monospace',color:'#fff',
cursor:'pointer',overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap' }}>
{col.title}
)}
{col.cards.length}
onDelete(col.id)} title="Spalte löschen"
style={{ background:'transparent',border:'none',color:'rgba(255,255,255,0.2)',
cursor:'pointer',fontSize:13,padding:'0 2px',lineHeight:1,flexShrink:0 }}>✕
);
}
// ── Einzelne Karte ─────────────────────────────────────────────────────────────
function KanbanCard({ card, col, allCols, onEdit, onDelete, onMove, isDragging, dragHandlers }) {
const prio = prioOf(card.priority);
const colIdx = allCols.findIndex(c => c.id === col.id);
const cardIdx = col.cards.findIndex(c => c.id === card.id);
const isFirst = cardIdx === 0;
const isLast = cardIdx === col.cards.length - 1;
const isFirstCol = colIdx === 0;
const isLastCol = colIdx === allCols.length - 1;
const arrowBtn = (label, disabled, onClick) => (
{label}
);
return (
{/* Titel-Zeile */}
{card.title}
onEdit(card)} title="Bearbeiten"
style={{ background:'transparent',border:'none',color:'rgba(255,255,255,0.35)',
cursor:'pointer',fontSize:12,padding:'1px 3px',lineHeight:1 }}>✎
onDelete(card.id)} title="Löschen"
style={{ background:'transparent',border:'none',color:'rgba(255,255,255,0.2)',
cursor:'pointer',fontSize:12,padding:'1px 3px',lineHeight:1 }}>✕
{/* Beschreibung */}
{card.description && (
{card.description}
)}
{/* Priorität + Pfeile */}
{prio.id !== 'none' ? (
{prio.label.toUpperCase()}
) :
}
{arrowBtn('↑', isFirst, ()=>onMove(card.id, col.id, cardIdx-1))}
{arrowBtn('↓', isLast, ()=>onMove(card.id, col.id, cardIdx+1))}
{arrowBtn('←', isFirstCol, ()=>onMove(card.id, allCols[colIdx-1]?.id))}
{arrowBtn('→', isLastCol, ()=>onMove(card.id, allCols[colIdx+1]?.id))}
{/* Erstellungsdatum */}
{card.created_at && (
{fmtCardDate(card.created_at)}
)}
);
}
// ── Hauptkomponente ───────────────────────────────────────────────────────────
export default function Kanban({ toast, mobile }) {
const [columns, setColumns] = useState([]);
const [loading, setLoading] = useState(true);
const [modal, setModal] = useState(null); // null | { card?, columnId }
const [newColInput, setNewColInput] = useState('');
const [addingCol, setAddingCol] = useState(false);
const [dragCardId, setDragCardId] = useState(null);
const [dragOverCol, setDragOverCol] = useState(null);
const [dragOverPos, setDragOverPos] = useState(null);
const newColRef = useRef(null);
const load = useCallback(() => {
setLoading(true);
api('/tools/kanban/board')
.then(d => setColumns(d.columns || []))
.catch(() => toast('Fehler beim Laden', 'error'))
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
useEffect(() => { if (addingCol) newColRef.current?.focus(); }, [addingCol]);
// ── Spalten-Aktionen ────────────────────────────────────────────────────────
const addColumn = async () => {
if (!newColInput.trim()) return;
try {
await api('/tools/kanban/columns', { body: { title: newColInput.trim() } });
setNewColInput(''); setAddingCol(false); load();
} catch (e) { toast(e.message, 'error'); }
};
const updateColumn = async (id, patch) => {
await api(`/tools/kanban/columns/${id}`, { method:'PATCH', body: patch });
load();
};
const deleteColumn = async (id) => {
const col = columns.find(c => c.id === id);
const msg = col?.cards?.length
? `Spalte "${col.title}" und alle ${col.cards.length} Karte(n) löschen?`
: `Spalte "${col?.title}" löschen?`;
if (!confirm(msg)) return;
try { await api(`/tools/kanban/columns/${id}`, { method:'DELETE' }); load(); }
catch (e) { toast(e.message, 'error'); }
};
// ── Karten-Aktionen ─────────────────────────────────────────────────────────
const deleteCard = async (id) => {
if (!confirm('Karte löschen?')) return;
try { await api(`/tools/kanban/cards/${id}`, { method:'DELETE' }); load(); }
catch (e) { toast(e.message, 'error'); }
};
const moveCard = async (cardId, colIdOrPos, posOrUndef) => {
// Aufruf-Varianten:
// onMove(id, col.id, newPos) → Position innerhalb Spalte
// onMove(id, targetColId) → Spalte wechseln, ans Ende
try {
if (posOrUndef !== undefined) {
await api(`/tools/kanban/cards/${cardId}/move`, { body:{ column_id:colIdOrPos, position:posOrUndef } });
} else {
await api(`/tools/kanban/cards/${cardId}/move`, { body:{ column_id:colIdOrPos } });
}
load();
} catch (e) { toast(e.message, 'error'); }
};
// ── Drag & Drop ─────────────────────────────────────────────────────────────
const onDragStart = (e, cardId) => {
setDragCardId(cardId);
e.dataTransfer.effectAllowed = 'move';
};
const onDragEnd = () => { setDragCardId(null); setDragOverCol(null); setDragOverPos(null); };
const onDragOverCard = (e, colId, pos) => {
e.preventDefault(); e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
setDragOverCol(colId); setDragOverPos(pos);
};
const onDragOverColBg = (e, colId) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const col = columns.find(c => c.id === colId);
setDragOverCol(colId); setDragOverPos(col ? col.cards.length : 0);
};
const onDrop = async (e, colId, pos) => {
e.preventDefault();
if (!dragCardId) return;
const targetPos = pos !== undefined ? pos : dragOverPos;
setDragCardId(null); setDragOverCol(null); setDragOverPos(null);
try {
await api(`/tools/kanban/cards/${dragCardId}/move`, { body:{ column_id:colId, position:targetPos } });
load();
} catch (err) { toast(err.message, 'error'); }
};
// ── Render ────────────────────────────────────────────────────────────────────
if (loading) return (
);
return (
{/* ── Header ─────────────────────────────────────────────────────────── */}
KANBAN BOARD
{addingCol ? (
setNewColInput(e.target.value)}
onKeyDown={e=>{ if(e.key==='Enter') addColumn(); if(e.key==='Escape'){setAddingCol(false);setNewColInput('');} }}
placeholder="Spaltenname" style={{ ...S.inp, width: mobile?140:180, fontSize:12 }} />
Hinzufügen
{setAddingCol(false);setNewColInput('');}} style={S.btn('#888888', true)}>✕
) : (
setAddingCol(true)} style={S.btn('#4ecdc4', true)}>+ Spalte
)}
{/* ── Board ──────────────────────────────────────────────────────────── */}
{columns.length === 0 ? (
Noch keine Spalten. Erstelle eine Spalte um loszulegen.
) : (
{columns.map(col => (
onDragOverColBg(e, col.id)}
onDrop={e=>onDrop(e, col.id, col.cards.length)}
style={{
...S.card,
minWidth: mobile ? 260 : 280,
maxWidth: 320,
width: mobile ? 260 : 280,
flexShrink:0,
display:'flex', flexDirection:'column',
padding:12, overflow:'hidden',
border:`1px solid ${dragOverCol===col.id && dragCardId ? (col.color||'#4ecdc4')+'66' : 'rgba(255,255,255,0.07)'}`,
transition:'border-color 0.15s',
}}>
{/* Karten-Liste */}
{col.cards.map((card, cardIdx) => (
onDragOverCard(e, col.id, cardIdx)}
onDrop={e=>{ e.stopPropagation(); onDrop(e, col.id, cardIdx); }}>
{dragCardId && dragOverCol===col.id && dragOverPos===cardIdx && (
)}
setModal({ card, columnId:col.id })}
onDelete={deleteCard}
onMove={moveCard}
isDragging={dragCardId===card.id}
dragHandlers={{ draggable:true, onDragStart:e=>onDragStart(e,card.id), onDragEnd }}
/>
))}
{dragCardId && dragOverCol===col.id && dragOverPos===col.cards.length && (
)}
{/* Karte hinzufügen */}
setModal({ card:null, columnId:col.id })}
style={{ ...S.btn(col.color||'#4ecdc4', true), width:'100%', marginTop:10, textAlign:'center' }}>
+ Karte
))}
)}
{/* ── Modal ──────────────────────────────────────────────────────────── */}
{modal && (
{ setModal(null); load(); }}
onClose={()=>setModal(null)}
toast={toast}
/>
)}
);
}