Files
dickendock/frontend/src/tools/kanban.jsx

514 lines
24 KiB
JavaScript
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 { 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 (
<div onClick={e => 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,
}}>
<div style={{ background:'#1a1a1e',border:'1px solid rgba(255,255,255,0.12)',
borderRadius:isMob?'16px 16px 0 0':14,
width:'100%',maxWidth:460,
display:'flex',flexDirection:'column',
maxHeight: isMob ? '80vh' : '90vh',
}}>
{/* Drag-Handle + Header immer sichtbar, nicht scrollend */}
<div style={{ padding:'18px 20px 0', flexShrink:0 }}>
{isMob && <div style={{ width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,margin:'0 auto 14px' }}/>}
<div style={{ display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:16 }}>
<div style={{ ...S.head, marginBottom:0 }}>{card ? 'KARTE BEARBEITEN' : 'NEUE KARTE'}</div>
<button onClick={onClose} style={{ background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18 }}></button>
</div>
</div>
{/* Scrollbarer Inhalt */}
<div style={{ overflowY:'auto',padding:'0 20px',flex:1,scrollbarWidth:'thin',scrollbarColor:'rgba(255,255,255,0.08) transparent' }}>
<input ref={titleRef} value={title} onChange={e=>setTitle(e.target.value)} onKeyDown={onKey}
placeholder="Titel" style={{ ...S.inp, marginBottom:10 }} />
<textarea value={desc} onChange={e=>setDesc(e.target.value)} onKeyDown={onKey}
placeholder="Beschreibung (optional)" rows={3}
style={{ ...S.inp, resize:'vertical', marginBottom:14 }} />
{/* Priorität */}
<div style={{ marginBottom:14 }}>
<div style={{ ...S.head, marginBottom:8 }}>PRIORITÄT</div>
<div style={{ display:'flex', gap:6, flexWrap:'wrap' }}>
{PRIORITIES.map(p => (
<button key={p.id} onClick={()=>setPrio(p.id)} style={{
...S.btn(p.color, true),
background: prio===p.id ? `${p.color}28` : `${p.color}0e`,
borderColor: prio===p.id ? p.color : `${p.color}35`,
fontWeight: prio===p.id ? 700 : 400,
}}>{p.label}</button>
))}
</div>
</div>
{/* Verschieben nach */}
<div style={{ marginBottom:14 }}>
<div style={{ ...S.head, marginBottom:8 }}>{card ? 'VERSCHIEBEN NACH' : 'IN SPALTE'}</div>
{!showNewCol ? (
<div style={{ display:'flex', gap:6, flexWrap:'wrap' }}>
{columns.map(c => (
<button key={c.id} onClick={()=>setMoveToCol(c.id)} style={{
...S.btn(c.color || '#4ecdc4', true),
background: moveToCol===c.id ? `${c.color||'#4ecdc4'}28` : `${c.color||'#4ecdc4'}0e`,
borderColor: moveToCol===c.id ? (c.color||'#4ecdc4') : `${c.color||'#4ecdc4'}35`,
fontWeight: moveToCol===c.id ? 700 : 400,
}}>{c.title}</button>
))}
<button onClick={()=>setShowNewCol(true)} style={S.btn('#888888', true)}>+ Neue Spalte</button>
</div>
) : (
<div style={{ display:'flex', gap:6 }}>
<input ref={newColRef} value={newColName} onChange={e=>setNewColName(e.target.value)}
onKeyDown={e=>{ if(e.key==='Escape'){setShowNewCol(false);setNewColName('');} }}
placeholder="Name der neuen Spalte"
style={{ ...S.inp, flex:1, fontSize:12 }} />
<button onClick={()=>{setShowNewCol(false);setNewColName('');}} style={S.btn('#888888',true)}></button>
</div>
)}
</div>
</div>
{/* Aktionsbuttons immer sichtbar am unteren Rand, nicht scrollend */}
<div style={{ padding:'14px 20px 24px', flexShrink:0, borderTop:'1px solid rgba(255,255,255,0.07)',
display:'flex', gap:8, justifyContent:'flex-end' }}>
<button onClick={onClose} style={S.btn('#888888', true)}>Abbrechen</button>
<button onClick={save} disabled={!title.trim()||saving} style={S.btn('#4ecdc4', true)}>
{saving ? '…' : card ? 'Speichern' : 'Erstellen'}
</button>
</div>
</div>
</div>
);
}
// ── 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 (
<div style={{ marginBottom:12 }}>
{/* Farbstreifen oben */}
<div style={{ height:3,borderRadius:'2px 2px 0 0',background:col.color||'#4ecdc4',margin:'-12px -12px 10px',marginTop:-12 }}/>
<div style={{ display:'flex', alignItems:'center', gap:6 }}>
{/* Farbpicker-Button */}
<div style={{ position:'relative' }}>
<button onClick={()=>setShowColor(v=>!v)} title="Farbe ändern"
style={{ width:16,height:16,borderRadius:'50%',background:col.color||'#4ecdc4',
border:'2px solid rgba(255,255,255,0.2)',cursor:'pointer',flexShrink:0,padding:0 }}/>
{showColor && (
<div style={{ position:'absolute',top:22,left:0,zIndex:200,
background:'#1a1a1e',border:'1px solid rgba(255,255,255,0.12)',
borderRadius:10,padding:8,display:'flex',flexWrap:'wrap',gap:6,width:130 }}>
{COL_COLORS.map(c => (
<button key={c} onClick={()=>setColor(c)} style={{
width:22,height:22,borderRadius:'50%',background:c,border:
(col.color||'#4ecdc4')===c?'2px solid #fff':'2px solid transparent',
cursor:'pointer',padding:0,transition:'border 0.1s',
}}/>
))}
</div>
)}
</div>
{/* Titel */}
{editTitle ? (
<input ref={inputRef} value={titleVal} onChange={e=>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 }} />
) : (
<span onDoubleClick={()=>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}
</span>
)}
<span style={{ color:'rgba(255,255,255,0.3)',fontSize:11,fontFamily:'monospace',flexShrink:0 }}>
{col.cards.length}
</span>
<button onClick={()=>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 }}></button>
</div>
</div>
);
}
// ── 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) => (
<button onClick={disabled ? undefined : onClick} disabled={disabled}
style={{ background:'transparent',border:'none',padding:'1px 4px',lineHeight:1,fontSize:13,
color: disabled ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.4)',
cursor: disabled ? 'default' : 'pointer' }}>
{label}
</button>
);
return (
<div {...dragHandlers} style={{
background: isDragging ? 'rgba(78,205,196,0.06)' : 'rgba(255,255,255,0.04)',
border: `1px solid ${isDragging ? '#4ecdc4' : 'rgba(255,255,255,0.08)'}`,
borderLeft: `3px solid ${prio.color}`,
borderRadius:8, padding:'10px 10px 8px', marginBottom:6,
cursor:'grab', opacity: isDragging ? 0.45 : 1,
transition:'border-color 0.12s, opacity 0.12s', userSelect:'none',
}}>
{/* Titel-Zeile */}
<div style={{ display:'flex',alignItems:'flex-start',gap:6,marginBottom:card.description?6:8 }}>
<span style={{ flex:1,fontSize:13,fontFamily:'monospace',color:'#fff',wordBreak:'break-word',lineHeight:1.4 }}>
{card.title}
</span>
<div style={{ display:'flex',gap:3,flexShrink:0 }}>
<button onClick={()=>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 }}></button>
<button onClick={()=>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 }}></button>
</div>
</div>
{/* Beschreibung */}
{card.description && (
<div style={{ fontSize:11,color:'rgba(255,255,255,0.4)',fontFamily:'monospace',
lineHeight:1.4,whiteSpace:'pre-wrap',wordBreak:'break-word',marginBottom:8 }}>
{card.description}
</div>
)}
{/* Priorität + Pfeile */}
<div style={{ display:'flex',alignItems:'center',justifyContent:'space-between',gap:4 }}>
{prio.id !== 'none' ? (
<span style={{ fontSize:9,fontFamily:'monospace',letterSpacing:1,
color:prio.color, background:`${prio.color}15`,
border:`1px solid ${prio.color}30`, borderRadius:4, padding:'1px 5px' }}>
{prio.label.toUpperCase()}
</span>
) : <span/>}
<div style={{ display:'flex',gap:0,flexShrink:0 }}>
{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))}
</div>
</div>
{/* Erstellungsdatum */}
{card.created_at && (
<div style={{ fontSize:9,fontFamily:'monospace',color:'rgba(255,255,255,0.2)',
marginTop:6, textAlign:'right', letterSpacing:0.5 }}>
{fmtCardDate(card.created_at)}
</div>
)}
</div>
);
}
// ── 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 (
<div style={{ padding: mobile ? '14px 14px 90px' : '36px 44px' }}>
<div style={{ color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:12 }}>Lädt</div>
</div>
);
return (
<div style={{ padding: mobile ? '14px 14px 90px' : '36px 44px', height:'100%', boxSizing:'border-box', display:'flex', flexDirection:'column' }}>
{/* ── Header ─────────────────────────────────────────────────────────── */}
<div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:24, flexWrap:'wrap' }}>
<h2 style={{ margin:0, fontSize:15, fontFamily:'monospace', color:'rgba(255,255,255,0.55)', letterSpacing:2, fontWeight:400 }}>
KANBAN BOARD
</h2>
<div style={{ flex:1 }}/>
{addingCol ? (
<div style={{ display:'flex', gap:6 }}>
<input ref={newColRef} value={newColInput} onChange={e=>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 }} />
<button onClick={addColumn} style={S.btn('#4ecdc4', true)}>Hinzufügen</button>
<button onClick={()=>{setAddingCol(false);setNewColInput('');}} style={S.btn('#888888', true)}></button>
</div>
) : (
<button onClick={()=>setAddingCol(true)} style={S.btn('#4ecdc4', true)}>+ Spalte</button>
)}
</div>
{/* ── Board ──────────────────────────────────────────────────────────── */}
{columns.length === 0 ? (
<div style={{ ...S.card, textAlign:'center', padding:40 }}>
<div style={{ color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:12 }}>
Noch keine Spalten. Erstelle eine Spalte um loszulegen.
</div>
</div>
) : (
<div style={{ display:'flex', gap:14, overflowX:'auto', flex:1,
paddingBottom:12, scrollbarWidth:'thin', scrollbarColor:'rgba(255,255,255,0.08) transparent' }}>
{columns.map(col => (
<div key={col.id}
onDragOver={e=>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',
}}>
<ColumnHeader col={col} onUpdate={updateColumn} onDelete={deleteColumn} toast={toast}/>
{/* Karten-Liste */}
<div style={{ flex:1, overflowY:'auto', scrollbarWidth:'thin', scrollbarColor:'rgba(255,255,255,0.08) transparent' }}>
{col.cards.map((card, cardIdx) => (
<div key={card.id}
onDragOver={e=>onDragOverCard(e, col.id, cardIdx)}
onDrop={e=>{ e.stopPropagation(); onDrop(e, col.id, cardIdx); }}>
{dragCardId && dragOverCol===col.id && dragOverPos===cardIdx && (
<div style={{ height:3,background:col.color||'#4ecdc4',borderRadius:2,marginBottom:4,opacity:0.7 }}/>
)}
<KanbanCard
card={card} col={col} allCols={columns}
onEdit={card=>setModal({ card, columnId:col.id })}
onDelete={deleteCard}
onMove={moveCard}
isDragging={dragCardId===card.id}
dragHandlers={{ draggable:true, onDragStart:e=>onDragStart(e,card.id), onDragEnd }}
/>
</div>
))}
{dragCardId && dragOverCol===col.id && dragOverPos===col.cards.length && (
<div style={{ height:3,background:col.color||'#4ecdc4',borderRadius:2,marginTop:2,opacity:0.7 }}/>
)}
</div>
{/* Karte hinzufügen */}
<button onClick={()=>setModal({ card:null, columnId:col.id })}
style={{ ...S.btn(col.color||'#4ecdc4', true), width:'100%', marginTop:10, textAlign:'center' }}>
+ Karte
</button>
</div>
))}
</div>
)}
{/* ── Modal ──────────────────────────────────────────────────────────── */}
{modal && (
<CardModal
card={modal.card}
columnId={modal.columnId}
columns={columns}
onSave={()=>{ setModal(null); load(); }}
onClose={()=>setModal(null)}
toast={toast}
/>
)}
</div>
);
}