Kanban: Design-Fix (padding/header wie andere Tools), Spaltenfarben, Verschieben-nach im Modal
This commit is contained in:
@@ -10,6 +10,7 @@ const router = express.Router();
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER NOT NULL,
|
user_id INTEGER NOT NULL,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL DEFAULT '#4ecdc4',
|
||||||
position INTEGER NOT NULL DEFAULT 0,
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
||||||
);
|
);
|
||||||
@@ -25,12 +26,16 @@ const router = express.Router();
|
|||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
// Nachrüsten falls Tabelle schon existiert aber color fehlt
|
||||||
|
const cols = db.pragma('table_info(kanban_columns)').map(c => c.name);
|
||||||
|
if (!cols.includes('color')) {
|
||||||
|
db.exec("ALTER TABLE kanban_columns ADD COLUMN color TEXT NOT NULL DEFAULT '#4ecdc4'");
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const uid = req => req.user.id;
|
const uid = req => req.user.id;
|
||||||
|
|
||||||
// ── Alle Spalten + Karten laden ───────────────────────────────────────────────
|
// ── Alle Spalten + Karten laden ───────────────────────────────────────────────
|
||||||
// Feste Routen vor :param-Routen (Lessons Learned)
|
|
||||||
router.get('/board', authenticate, (req, res) => {
|
router.get('/board', authenticate, (req, res) => {
|
||||||
const me = uid(req);
|
const me = uid(req);
|
||||||
const columns = db.prepare(
|
const columns = db.prepare(
|
||||||
@@ -39,7 +44,6 @@ router.get('/board', authenticate, (req, res) => {
|
|||||||
const cards = db.prepare(
|
const cards = db.prepare(
|
||||||
'SELECT * FROM kanban_cards WHERE user_id=? ORDER BY position ASC, id ASC'
|
'SELECT * FROM kanban_cards WHERE user_id=? ORDER BY position ASC, id ASC'
|
||||||
).all(me);
|
).all(me);
|
||||||
// Karten in Spalten einsortieren
|
|
||||||
const colMap = {};
|
const colMap = {};
|
||||||
for (const c of columns) { c.cards = []; colMap[c.id] = c; }
|
for (const c of columns) { c.cards = []; colMap[c.id] = c; }
|
||||||
for (const card of cards) {
|
for (const card of cards) {
|
||||||
@@ -50,29 +54,39 @@ router.get('/board', authenticate, (req, res) => {
|
|||||||
|
|
||||||
// ── Spalte erstellen ──────────────────────────────────────────────────────────
|
// ── Spalte erstellen ──────────────────────────────────────────────────────────
|
||||||
router.post('/columns', authenticate, (req, res) => {
|
router.post('/columns', authenticate, (req, res) => {
|
||||||
const { title } = req.body;
|
const { title, color = '#4ecdc4' } = req.body;
|
||||||
if (!title?.trim()) return res.status(400).json({ error: 'Titel fehlt' });
|
if (!title?.trim()) return res.status(400).json({ error: 'Titel fehlt' });
|
||||||
const me = uid(req);
|
const me = uid(req);
|
||||||
const maxPos = db.prepare('SELECT MAX(position) as m FROM kanban_columns WHERE user_id=?').get(me);
|
const maxPos = db.prepare('SELECT MAX(position) as m FROM kanban_columns WHERE user_id=?').get(me);
|
||||||
const pos = (maxPos?.m ?? -1) + 1;
|
const pos = (maxPos?.m ?? -1) + 1;
|
||||||
const r = db.prepare(
|
const r = db.prepare(
|
||||||
"INSERT INTO kanban_columns (user_id,title,position) VALUES (?,?,?)"
|
"INSERT INTO kanban_columns (user_id,title,color,position) VALUES (?,?,?,?)"
|
||||||
).run(me, title.trim(), pos);
|
).run(me, title.trim(), color, pos);
|
||||||
res.json(db.prepare('SELECT * FROM kanban_columns WHERE id=?').get(r.lastInsertRowid));
|
res.json(db.prepare('SELECT * FROM kanban_columns WHERE id=?').get(r.lastInsertRowid));
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Spalte umbenennen ─────────────────────────────────────────────────────────
|
// ── Spalte aktualisieren (Titel + Farbe) ─────────────────────────────────────
|
||||||
router.patch('/columns/:id', authenticate, (req, res) => {
|
// Feste Route vor :param (Lesson Learned)
|
||||||
const { title } = req.body;
|
router.post('/columns/reorder', authenticate, (req, res) => {
|
||||||
if (!title?.trim()) return res.status(400).json({ error: 'Titel fehlt' });
|
const { order } = req.body;
|
||||||
|
if (!Array.isArray(order)) return res.status(400).json({ error: 'order fehlt' });
|
||||||
const me = uid(req);
|
const me = uid(req);
|
||||||
const col = db.prepare('SELECT * FROM kanban_columns WHERE id=? AND user_id=?').get(req.params.id, me);
|
const upd = db.prepare('UPDATE kanban_columns SET position=? WHERE id=? AND user_id=?');
|
||||||
if (!col) return res.status(404).json({ error: 'Spalte nicht gefunden' });
|
db.transaction(() => { for (let i = 0; i < order.length; i++) upd.run(i, order[i], me); })();
|
||||||
db.prepare('UPDATE kanban_columns SET title=? WHERE id=?').run(title.trim(), col.id);
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch('/columns/:id', authenticate, (req, res) => {
|
||||||
|
const me = uid(req);
|
||||||
|
const col = db.prepare('SELECT * FROM kanban_columns WHERE id=? AND user_id=?').get(req.params.id, me);
|
||||||
|
if (!col) return res.status(404).json({ error: 'Spalte nicht gefunden' });
|
||||||
|
const title = req.body.title !== undefined ? req.body.title.trim() : col.title;
|
||||||
|
const color = req.body.color !== undefined ? req.body.color : col.color;
|
||||||
|
if (!title) return res.status(400).json({ error: 'Titel fehlt' });
|
||||||
|
db.prepare('UPDATE kanban_columns SET title=?, color=? WHERE id=?').run(title, color, col.id);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Spalte löschen (inkl. aller Karten via CASCADE) ──────────────────────────
|
|
||||||
router.delete('/columns/:id', authenticate, (req, res) => {
|
router.delete('/columns/:id', authenticate, (req, res) => {
|
||||||
const me = uid(req);
|
const me = uid(req);
|
||||||
const col = db.prepare('SELECT * FROM kanban_columns WHERE id=? AND user_id=?').get(req.params.id, me);
|
const col = db.prepare('SELECT * FROM kanban_columns WHERE id=? AND user_id=?').get(req.params.id, me);
|
||||||
@@ -81,20 +95,7 @@ router.delete('/columns/:id', authenticate, (req, res) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Spalten-Reihenfolge speichern ─────────────────────────────────────────────
|
// ── Karten ────────────────────────────────────────────────────────────────────
|
||||||
router.post('/columns/reorder', authenticate, (req, res) => {
|
|
||||||
const { order } = req.body; // Array von column-IDs in neuer Reihenfolge
|
|
||||||
if (!Array.isArray(order)) return res.status(400).json({ error: 'order fehlt' });
|
|
||||||
const me = uid(req);
|
|
||||||
const upd = db.prepare('UPDATE kanban_columns SET position=? WHERE id=? AND user_id=?');
|
|
||||||
const tx = db.transaction(() => {
|
|
||||||
for (let i = 0; i < order.length; i++) upd.run(i, order[i], me);
|
|
||||||
});
|
|
||||||
tx();
|
|
||||||
res.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Karte erstellen ───────────────────────────────────────────────────────────
|
|
||||||
router.post('/cards', authenticate, (req, res) => {
|
router.post('/cards', authenticate, (req, res) => {
|
||||||
const { column_id, title, description = '', priority = 'none' } = req.body;
|
const { column_id, title, description = '', priority = 'none' } = req.body;
|
||||||
if (!column_id || !title?.trim()) return res.status(400).json({ error: 'column_id und title erforderlich' });
|
if (!column_id || !title?.trim()) return res.status(400).json({ error: 'column_id und title erforderlich' });
|
||||||
@@ -109,7 +110,6 @@ router.post('/cards', authenticate, (req, res) => {
|
|||||||
res.json(db.prepare('SELECT * FROM kanban_cards WHERE id=?').get(r.lastInsertRowid));
|
res.json(db.prepare('SELECT * FROM kanban_cards WHERE id=?').get(r.lastInsertRowid));
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Karte bearbeiten ──────────────────────────────────────────────────────────
|
|
||||||
router.patch('/cards/:id', authenticate, (req, res) => {
|
router.patch('/cards/:id', authenticate, (req, res) => {
|
||||||
const me = uid(req);
|
const me = uid(req);
|
||||||
const card = db.prepare('SELECT * FROM kanban_cards WHERE id=? AND user_id=?').get(req.params.id, me);
|
const card = db.prepare('SELECT * FROM kanban_cards WHERE id=? AND user_id=?').get(req.params.id, me);
|
||||||
@@ -124,7 +124,6 @@ router.patch('/cards/:id', authenticate, (req, res) => {
|
|||||||
res.json(db.prepare('SELECT * FROM kanban_cards WHERE id=?').get(card.id));
|
res.json(db.prepare('SELECT * FROM kanban_cards WHERE id=?').get(card.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Karte löschen ─────────────────────────────────────────────────────────────
|
|
||||||
router.delete('/cards/:id', authenticate, (req, res) => {
|
router.delete('/cards/:id', authenticate, (req, res) => {
|
||||||
const me = uid(req);
|
const me = uid(req);
|
||||||
const card = db.prepare('SELECT * FROM kanban_cards WHERE id=? AND user_id=?').get(req.params.id, me);
|
const card = db.prepare('SELECT * FROM kanban_cards WHERE id=? AND user_id=?').get(req.params.id, me);
|
||||||
@@ -133,38 +132,23 @@ router.delete('/cards/:id', authenticate, (req, res) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Karte verschieben (Spalte + Position) ─────────────────────────────────────
|
|
||||||
// Wird genutzt für: Drag&Drop zwischen Spalten, Pfeil-Buttons, Position hoch/runter
|
|
||||||
router.post('/cards/:id/move', authenticate, (req, res) => {
|
router.post('/cards/:id/move', authenticate, (req, res) => {
|
||||||
const { column_id, position } = req.body;
|
const { column_id, position } = req.body;
|
||||||
const me = uid(req);
|
const me = uid(req);
|
||||||
const card = db.prepare('SELECT * FROM kanban_cards WHERE id=? AND user_id=?').get(req.params.id, me);
|
const card = db.prepare('SELECT * FROM kanban_cards WHERE id=? AND user_id=?').get(req.params.id, me);
|
||||||
if (!card) return res.status(404).json({ error: 'Karte nicht gefunden' });
|
if (!card) return res.status(404).json({ error: 'Karte nicht gefunden' });
|
||||||
|
|
||||||
const targetColId = column_id !== undefined ? column_id : card.column_id;
|
const targetColId = column_id !== undefined ? column_id : card.column_id;
|
||||||
|
|
||||||
// Zielspalte gehört dem User?
|
|
||||||
const col = db.prepare('SELECT * FROM kanban_columns WHERE id=? AND user_id=?').get(targetColId, me);
|
const col = db.prepare('SELECT * FROM kanban_columns WHERE id=? AND user_id=?').get(targetColId, me);
|
||||||
if (!col) return res.status(404).json({ error: 'Zielspalte nicht gefunden' });
|
if (!col) return res.status(404).json({ error: 'Zielspalte nicht gefunden' });
|
||||||
|
|
||||||
// Alle Karten der Zielspalte (ohne die verschobene), dann an position einfügen
|
|
||||||
const siblings = db.prepare(
|
const siblings = db.prepare(
|
||||||
'SELECT id FROM kanban_cards WHERE column_id=? AND id!=? ORDER BY position ASC, id ASC'
|
'SELECT id FROM kanban_cards WHERE column_id=? AND id!=? ORDER BY position ASC, id ASC'
|
||||||
).all(targetColId, card.id).map(r => r.id);
|
).all(targetColId, card.id).map(r => r.id);
|
||||||
|
|
||||||
const targetPos = position !== undefined
|
const targetPos = position !== undefined
|
||||||
? Math.max(0, Math.min(position, siblings.length))
|
? Math.max(0, Math.min(position, siblings.length))
|
||||||
: siblings.length; // ans Ende wenn keine position angegeben
|
: siblings.length;
|
||||||
|
|
||||||
siblings.splice(targetPos, 0, card.id);
|
siblings.splice(targetPos, 0, card.id);
|
||||||
|
|
||||||
const upd = db.prepare('UPDATE kanban_cards SET column_id=?, position=? WHERE id=?');
|
const upd = db.prepare('UPDATE kanban_cards SET column_id=?, position=? WHERE id=?');
|
||||||
const tx = db.transaction(() => {
|
db.transaction(() => { for (let i = 0; i < siblings.length; i++) upd.run(targetColId, i, siblings[i]); })();
|
||||||
for (let i = 0; i < siblings.length; i++) {
|
|
||||||
upd.run(targetColId, i, siblings[i]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
tx();
|
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,84 +1,126 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { api, S } from '../lib.js';
|
import { api, S } from '../lib.js';
|
||||||
|
|
||||||
// ── Prioritäten ───────────────────────────────────────────────────────────────
|
// ── Konstanten ────────────────────────────────────────────────────────────────
|
||||||
const PRIORITIES = [
|
const PRIORITIES = [
|
||||||
{ id: 'none', label: 'Keine', color: '#888888' },
|
{ id: 'none', label: 'Keine', color: '#666666' },
|
||||||
{ id: 'low', label: 'Niedrig', color: '#4ecdc4' },
|
{ id: 'low', label: 'Niedrig', color: '#4ecdc4' },
|
||||||
{ id: 'medium', label: 'Mittel', color: '#ffe66d' },
|
{ id: 'medium', label: 'Mittel', color: '#ffe66d' },
|
||||||
{ id: 'high', label: 'Hoch', color: '#ff6b9d' },
|
{ id: 'high', label: 'Hoch', color: '#ff6b9d' },
|
||||||
];
|
];
|
||||||
|
const COL_COLORS = ['#4ecdc4','#60a5fa','#a78bfa','#ff6b9d','#ffe66d','#fb923c','#4ade80','#f472b6','#94a3b8'];
|
||||||
const prioOf = id => PRIORITIES.find(p => p.id === id) || PRIORITIES[0];
|
const prioOf = id => PRIORITIES.find(p => p.id === id) || PRIORITIES[0];
|
||||||
|
|
||||||
// ── Karten-Modal (Erstellen + Bearbeiten) ─────────────────────────────────────
|
// ── Karten-Modal ──────────────────────────────────────────────────────────────
|
||||||
// Absichtlich AUSSERHALB der Hauptkomponente definiert (Lesson Learned: kein Remount)
|
function CardModal({ card, columnId, columns, onSave, onClose, toast }) {
|
||||||
function CardModal({ card, columnId, onSave, onClose }) {
|
|
||||||
const [title, setTitle] = useState(card?.title || '');
|
const [title, setTitle] = useState(card?.title || '');
|
||||||
const [desc, setDesc] = useState(card?.description || '');
|
const [desc, setDesc] = useState(card?.description || '');
|
||||||
const [prio, setPrio] = useState(card?.priority || 'none');
|
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 [saving, setSaving] = useState(false);
|
||||||
const titleRef = useRef(null);
|
const titleRef = useRef(null);
|
||||||
|
const newColRef = useRef(null);
|
||||||
|
const isMob = window.innerWidth < 768;
|
||||||
|
|
||||||
useEffect(() => { titleRef.current?.focus(); }, []);
|
useEffect(() => { titleRef.current?.focus(); }, []);
|
||||||
|
useEffect(() => { if (showNewCol) newColRef.current?.focus(); }, [showNewCol]);
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
if (!title.trim()) return;
|
if (!title.trim()) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
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) {
|
if (card) {
|
||||||
await api(`/tools/kanban/cards/${card.id}`, { method: 'PATCH', body: { title, description: desc, priority: prio } });
|
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 {
|
} else {
|
||||||
await api('/tools/kanban/cards', { body: { column_id: columnId, title, description: desc, priority: prio } });
|
await api('/tools/kanban/cards', { body: { column_id: targetCol || columnId, title, description: desc, priority: prio } });
|
||||||
}
|
}
|
||||||
onSave();
|
onSave();
|
||||||
} catch (e) { alert(e.message); }
|
} catch (e) { toast(e.message, 'error'); }
|
||||||
finally { setSaving(false); }
|
finally { setSaving(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const onKey = e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) save(); if (e.key === 'Escape') onClose(); };
|
const onKey = e => {
|
||||||
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) save();
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div onClick={onClose} style={{ position:'fixed',inset:0,background:'rgba(0,0,0,0.6)',zIndex:1000,display:'flex',alignItems:'center',justifyContent:'center',padding:16 }}>
|
<div onClick={e => e.target === e.currentTarget && onClose()}
|
||||||
<div onClick={e=>e.stopPropagation()} style={{ background:'#1a1a1e',border:'1px solid rgba(255,255,255,0.1)',borderRadius:14,padding:24,width:'100%',maxWidth:440 }}>
|
style={{ position:'fixed',inset:0,background:'rgba(0,0,0,0.7)',zIndex:1000,
|
||||||
<div style={{ color:'rgba(255,255,255,0.6)',fontSize:10,fontFamily:'monospace',letterSpacing:2,marginBottom:14 }}>
|
display:'flex',alignItems:isMob?'flex-end':'center',justifyContent:'center',padding:isMob?0:20 }}>
|
||||||
{card ? 'KARTE BEARBEITEN' : 'NEUE KARTE'}
|
<div style={{ background:'#1a1a1e',border:'1px solid rgba(255,255,255,0.12)',
|
||||||
|
borderRadius:isMob?'16px 16px 0 0':14,padding:'22px 20px 28px',
|
||||||
|
width:'100%',maxWidth:460 }}>
|
||||||
|
{isMob && <div style={{ width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,margin:'0 auto 16px' }}/>}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
<input
|
<input ref={titleRef} value={title} onChange={e=>setTitle(e.target.value)} onKeyDown={onKey}
|
||||||
ref={titleRef}
|
placeholder="Titel" style={{ ...S.inp, marginBottom:10 }} />
|
||||||
value={title}
|
|
||||||
onChange={e=>setTitle(e.target.value)}
|
|
||||||
onKeyDown={onKey}
|
|
||||||
placeholder="Titel"
|
|
||||||
style={{ ...S.inp, marginBottom: 10 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<textarea
|
<textarea value={desc} onChange={e=>setDesc(e.target.value)} onKeyDown={onKey}
|
||||||
value={desc}
|
placeholder="Beschreibung (optional)" rows={3}
|
||||||
onChange={e=>setDesc(e.target.value)}
|
style={{ ...S.inp, resize:'vertical', marginBottom:14 }} />
|
||||||
onKeyDown={onKey}
|
|
||||||
placeholder="Beschreibung (optional)"
|
|
||||||
rows={4}
|
|
||||||
style={{ ...S.inp, resize:'vertical', marginBottom:12 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Priorität */}
|
{/* Priorität */}
|
||||||
<div style={{ marginBottom:16 }}>
|
<div style={{ marginBottom:14 }}>
|
||||||
<div style={{ ...S.head, marginBottom:8 }}>PRIORITÄT</div>
|
<div style={{ ...S.head, marginBottom:8 }}>PRIORITÄT</div>
|
||||||
<div style={{ display:'flex', gap:6, flexWrap:'wrap' }}>
|
<div style={{ display:'flex', gap:6 }}>
|
||||||
{PRIORITIES.map(p => (
|
{PRIORITIES.map(p => (
|
||||||
<button key={p.id} onClick={()=>setPrio(p.id)}
|
<button key={p.id} onClick={()=>setPrio(p.id)} style={{
|
||||||
style={{ ...S.btn(p.color, true),
|
...S.btn(p.color, true),
|
||||||
background: prio===p.id ? `${p.color}30` : `${p.color}10`,
|
background: prio===p.id ? `${p.color}28` : `${p.color}0e`,
|
||||||
borderColor: prio===p.id ? p.color : `${p.color}40`,
|
borderColor: prio===p.id ? p.color : `${p.color}35`,
|
||||||
fontWeight: prio===p.id ? 700 : 400 }}>
|
fontWeight: prio===p.id ? 700 : 400,
|
||||||
{p.label}
|
}}>{p.label}</button>
|
||||||
</button>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Verschieben nach (nur bei Bearbeitung sinnvoll, aber auch bei Neu nützlich) */}
|
||||||
|
<div style={{ marginBottom:18 }}>
|
||||||
|
<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 style={{ display:'flex', gap:8, justifyContent:'flex-end' }}>
|
<div style={{ display:'flex', gap:8, justifyContent:'flex-end' }}>
|
||||||
<button onClick={onClose} style={S.btn('#888888', true)}>Abbrechen</button>
|
<button onClick={onClose} style={S.btn('#888888', true)}>Abbrechen</button>
|
||||||
<button onClick={save} disabled={!title.trim()||saving} style={S.btn('#4ecdc4', true)}>
|
<button onClick={save} disabled={!title.trim()||saving} style={S.btn('#4ecdc4', true)}>
|
||||||
@@ -90,81 +132,116 @@ function CardModal({ card, columnId, onSave, onClose }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Spalten-Titel Inline-Edit ──────────────────────────────────────────────────
|
// ── Spalten-Header ────────────────────────────────────────────────────────────
|
||||||
// Ebenfalls außerhalb definiert
|
function ColumnHeader({ col, onUpdate, onDelete, toast }) {
|
||||||
function ColumnTitle({ col, onRename, onDelete }) {
|
const [editTitle, setEditTitle] = useState(false);
|
||||||
const [editing, setEditing] = useState(false);
|
const [titleVal, setTitleVal] = useState(col.title);
|
||||||
const [val, setVal] = useState(col.title);
|
const [showColor, setShowColor] = useState(false);
|
||||||
const inputRef = useRef(null);
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => { if (editing) inputRef.current?.focus(); }, [editing]);
|
useEffect(() => { if (editTitle) inputRef.current?.focus(); }, [editTitle]);
|
||||||
|
|
||||||
const commit = async () => {
|
const commitTitle = async () => {
|
||||||
if (!val.trim()) { setVal(col.title); setEditing(false); return; }
|
setEditTitle(false);
|
||||||
if (val.trim() !== col.title) await onRename(col.id, val.trim());
|
if (titleVal.trim() && titleVal.trim() !== col.title) {
|
||||||
setEditing(false);
|
try { await onUpdate(col.id, { title: titleVal.trim() }); }
|
||||||
|
catch (e) { toast(e.message, 'error'); setTitleVal(col.title); }
|
||||||
|
} else { setTitleVal(col.title); }
|
||||||
};
|
};
|
||||||
|
|
||||||
if (editing) return (
|
const setColor = async (color) => {
|
||||||
<input
|
setShowColor(false);
|
||||||
ref={inputRef}
|
try { await onUpdate(col.id, { color }); }
|
||||||
value={val}
|
catch (e) { toast(e.message, 'error'); }
|
||||||
onChange={e=>setVal(e.target.value)}
|
};
|
||||||
onBlur={commit}
|
|
||||||
onKeyDown={e=>{ if(e.key==='Enter') commit(); if(e.key==='Escape'){setVal(col.title);setEditing(false);} }}
|
|
||||||
style={{ ...S.inp, fontSize:13, fontWeight:700, padding:'4px 8px', flex:1 }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display:'flex', alignItems:'center', gap:6, flex:1, minWidth:0 }}>
|
<div style={{ marginBottom:12 }}>
|
||||||
<span
|
{/* Farbstreifen oben */}
|
||||||
onClick={()=>setEditing(true)}
|
<div style={{ height:3,borderRadius:'2px 2px 0 0',background:col.color||'#4ecdc4',margin:'-12px -12px 10px',marginTop:-12 }}/>
|
||||||
title="Doppelklick zum Umbenennen"
|
|
||||||
onDoubleClick={()=>setEditing(true)}
|
<div style={{ display:'flex', alignItems:'center', gap:6 }}>
|
||||||
style={{ fontWeight:700, fontSize:13, fontFamily:'monospace', color:'#fff',
|
{/* Farbpicker-Button */}
|
||||||
cursor:'pointer', flex:1, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
|
<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}
|
{col.title}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
<span style={{ color:'rgba(255,255,255,0.3)',fontSize:11,fontFamily:'monospace',flexShrink:0 }}>
|
<span style={{ color:'rgba(255,255,255,0.3)',fontSize:11,fontFamily:'monospace',flexShrink:0 }}>
|
||||||
{col.cards.length}
|
{col.cards.length}
|
||||||
</span>
|
</span>
|
||||||
<button onClick={()=>onDelete(col.id)} title="Spalte löschen"
|
<button onClick={()=>onDelete(col.id)} title="Spalte löschen"
|
||||||
style={{ background:'transparent', border:'none', color:'rgba(255,255,255,0.25)',
|
style={{ background:'transparent',border:'none',color:'rgba(255,255,255,0.2)',
|
||||||
cursor:'pointer', fontSize:14, padding:'0 2px', flexShrink:0, lineHeight:1 }}>
|
cursor:'pointer',fontSize:13,padding:'0 2px',lineHeight:1,flexShrink:0 }}>✕</button>
|
||||||
✕
|
</div>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Einzelne Karte ─────────────────────────────────────────────────────────────
|
// ── Einzelne Karte ─────────────────────────────────────────────────────────────
|
||||||
function KanbanCard({ card, col, allCols, onEdit, onDelete, onMove, onMoveCol, isDragging, dragHandlers }) {
|
function KanbanCard({ card, col, allCols, onEdit, onDelete, onMove, isDragging, dragHandlers }) {
|
||||||
const prio = prioOf(card.priority);
|
const prio = prioOf(card.priority);
|
||||||
const colIdx = allCols.findIndex(c => c.id === col.id);
|
const colIdx = allCols.findIndex(c => c.id === col.id);
|
||||||
const cardIdx = col.cards.findIndex(c => c.id === card.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 (
|
return (
|
||||||
<div
|
<div {...dragHandlers} style={{
|
||||||
{...dragHandlers}
|
background: isDragging ? 'rgba(78,205,196,0.06)' : 'rgba(255,255,255,0.04)',
|
||||||
style={{
|
|
||||||
background: isDragging ? 'rgba(78,205,196,0.08)' : 'rgba(255,255,255,0.04)',
|
|
||||||
border: `1px solid ${isDragging ? '#4ecdc4' : 'rgba(255,255,255,0.08)'}`,
|
border: `1px solid ${isDragging ? '#4ecdc4' : 'rgba(255,255,255,0.08)'}`,
|
||||||
borderLeft: `3px solid ${prio.color}`,
|
borderLeft: `3px solid ${prio.color}`,
|
||||||
borderRadius:8, padding:'10px 10px 8px', marginBottom:6,
|
borderRadius:8, padding:'10px 10px 8px', marginBottom:6,
|
||||||
cursor: 'grab', opacity: isDragging ? 0.5 : 1,
|
cursor:'grab', opacity: isDragging ? 0.45 : 1,
|
||||||
transition: 'border-color 0.15s, opacity 0.15s',
|
transition:'border-color 0.12s, opacity 0.12s', userSelect:'none',
|
||||||
userSelect: 'none',
|
}}>
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Titel-Zeile */}
|
{/* Titel-Zeile */}
|
||||||
<div style={{ display:'flex', alignItems:'flex-start', gap:6, marginBottom: card.description ? 6 : 0 }}>
|
<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 }}>
|
<span style={{ flex:1,fontSize:13,fontFamily:'monospace',color:'#fff',wordBreak:'break-word',lineHeight:1.4 }}>
|
||||||
{card.title}
|
{card.title}
|
||||||
</span>
|
</span>
|
||||||
<div style={{ display:'flex', gap:4, flexShrink:0 }}>
|
<div style={{ display:'flex',gap:3,flexShrink:0 }}>
|
||||||
<button onClick={()=>onEdit(card)} title="Bearbeiten"
|
<button onClick={()=>onEdit(card)} title="Bearbeiten"
|
||||||
style={{ background:'transparent',border:'none',color:'rgba(255,255,255,0.3)',
|
style={{ background:'transparent',border:'none',color:'rgba(255,255,255,0.35)',
|
||||||
cursor:'pointer',fontSize:12,padding:'1px 3px',lineHeight:1 }}>✎</button>
|
cursor:'pointer',fontSize:12,padding:'1px 3px',lineHeight:1 }}>✎</button>
|
||||||
<button onClick={()=>onDelete(card.id)} title="Löschen"
|
<button onClick={()=>onDelete(card.id)} title="Löschen"
|
||||||
style={{ background:'transparent',border:'none',color:'rgba(255,255,255,0.2)',
|
style={{ background:'transparent',border:'none',color:'rgba(255,255,255,0.2)',
|
||||||
@@ -180,44 +257,21 @@ function KanbanCard({ card, col, allCols, onEdit, onDelete, onMove, onMoveCol, i
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Steuerzeile: Priorität-Badge + Pfeile */}
|
{/* Priorität + Pfeile */}
|
||||||
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', marginTop:4 }}>
|
<div style={{ display:'flex',alignItems:'center',justifyContent:'space-between',gap:4 }}>
|
||||||
|
{prio.id !== 'none' ? (
|
||||||
<span style={{ fontSize:9,fontFamily:'monospace',letterSpacing:1,
|
<span style={{ fontSize:9,fontFamily:'monospace',letterSpacing:1,
|
||||||
color:prio.color, background:`${prio.color}15`,
|
color:prio.color, background:`${prio.color}15`,
|
||||||
border:`1px solid ${prio.color}30`, borderRadius:4, padding:'1px 5px' }}>
|
border:`1px solid ${prio.color}30`, borderRadius:4, padding:'1px 5px' }}>
|
||||||
{prio.label.toUpperCase()}
|
{prio.label.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
|
) : <span/>}
|
||||||
|
|
||||||
{/* Positions- & Spalten-Navigation */}
|
<div style={{ display:'flex',gap:0,flexShrink:0 }}>
|
||||||
<div style={{ display:'flex', gap:3 }}>
|
{arrowBtn('↑', isFirst, ()=>onMove(card.id, col.id, cardIdx-1))}
|
||||||
{/* Position hoch (innerhalb Spalte) */}
|
{arrowBtn('↓', isLast, ()=>onMove(card.id, col.id, cardIdx+1))}
|
||||||
<button onClick={()=>onMove(card.id, col.id, cardIdx-1)} disabled={cardIdx===0}
|
{arrowBtn('←', isFirstCol, ()=>onMove(card.id, allCols[colIdx-1]?.id))}
|
||||||
title="Nach oben" style={{ background:'transparent',border:'none',
|
{arrowBtn('→', isLastCol, ()=>onMove(card.id, allCols[colIdx+1]?.id))}
|
||||||
color: cardIdx===0 ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.35)',
|
|
||||||
cursor: cardIdx===0 ? 'default':'pointer', fontSize:13, padding:'1px 3px', lineHeight:1 }}>
|
|
||||||
↑
|
|
||||||
</button>
|
|
||||||
{/* Position runter */}
|
|
||||||
<button onClick={()=>onMove(card.id, col.id, cardIdx+1)} disabled={cardIdx===col.cards.length-1}
|
|
||||||
title="Nach unten" style={{ background:'transparent',border:'none',
|
|
||||||
color: cardIdx===col.cards.length-1 ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.35)',
|
|
||||||
cursor: cardIdx===col.cards.length-1 ? 'default':'pointer', fontSize:13, padding:'1px 3px', lineHeight:1 }}>
|
|
||||||
↓
|
|
||||||
</button>
|
|
||||||
{/* Spalte links */}
|
|
||||||
<button onClick={()=>onMoveCol(card.id, allCols[colIdx-1]?.id)} disabled={colIdx===0}
|
|
||||||
title="Spalte zurück" style={{ background:'transparent',border:'none',
|
|
||||||
color: colIdx===0 ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.35)',
|
|
||||||
cursor: colIdx===0 ? 'default':'pointer', fontSize:13, padding:'1px 3px', lineHeight:1 }}>
|
|
||||||
←
|
|
||||||
</button>
|
|
||||||
{/* Spalte rechts */}
|
|
||||||
<button onClick={()=>onMoveCol(card.id, allCols[colIdx+1]?.id)} disabled={colIdx===allCols.length-1}
|
|
||||||
title="Nächste Spalte" style={{ background:'transparent',border:'none',
|
|
||||||
color: colIdx===allCols.length-1 ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.35)',
|
|
||||||
cursor: colIdx===allCols.length-1 ? 'default':'pointer', fontSize:13, padding:'1px 3px', lineHeight:1 }}>
|
|
||||||
→
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -225,7 +279,7 @@ function KanbanCard({ card, col, allCols, onEdit, onDelete, onMove, onMoveCol, i
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Hauptkomponente ───────────────────────────────────────────────────────────
|
// ── Hauptkomponente ───────────────────────────────────────────────────────────
|
||||||
export default function Kanban() {
|
export default function Kanban({ toast, mobile }) {
|
||||||
const [columns, setColumns] = useState([]);
|
const [columns, setColumns] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [modal, setModal] = useState(null); // null | { card?, columnId }
|
const [modal, setModal] = useState(null); // null | { card?, columnId }
|
||||||
@@ -236,12 +290,11 @@ export default function Kanban() {
|
|||||||
const [dragOverPos, setDragOverPos] = useState(null);
|
const [dragOverPos, setDragOverPos] = useState(null);
|
||||||
const newColRef = useRef(null);
|
const newColRef = useRef(null);
|
||||||
|
|
||||||
// Hooks immer vor Early Returns (Lesson Learned)
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
api('/tools/kanban/board')
|
api('/tools/kanban/board')
|
||||||
.then(d => setColumns(d.columns || []))
|
.then(d => setColumns(d.columns || []))
|
||||||
.catch(e => console.error('Kanban load error', e))
|
.catch(() => toast('Fehler beim Laden', 'error'))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -254,12 +307,12 @@ export default function Kanban() {
|
|||||||
try {
|
try {
|
||||||
await api('/tools/kanban/columns', { body: { title: newColInput.trim() } });
|
await api('/tools/kanban/columns', { body: { title: newColInput.trim() } });
|
||||||
setNewColInput(''); setAddingCol(false); load();
|
setNewColInput(''); setAddingCol(false); load();
|
||||||
} catch (e) { alert(e.message); }
|
} catch (e) { toast(e.message, 'error'); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const renameColumn = async (id, title) => {
|
const updateColumn = async (id, patch) => {
|
||||||
try { await api(`/tools/kanban/columns/${id}`, { method:'PATCH', body:{ title } }); load(); }
|
await api(`/tools/kanban/columns/${id}`, { method:'PATCH', body: patch });
|
||||||
catch (e) { alert(e.message); }
|
load();
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteColumn = async (id) => {
|
const deleteColumn = async (id) => {
|
||||||
@@ -269,27 +322,28 @@ export default function Kanban() {
|
|||||||
: `Spalte "${col?.title}" löschen?`;
|
: `Spalte "${col?.title}" löschen?`;
|
||||||
if (!confirm(msg)) return;
|
if (!confirm(msg)) return;
|
||||||
try { await api(`/tools/kanban/columns/${id}`, { method:'DELETE' }); load(); }
|
try { await api(`/tools/kanban/columns/${id}`, { method:'DELETE' }); load(); }
|
||||||
catch (e) { alert(e.message); }
|
catch (e) { toast(e.message, 'error'); }
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Karten-Aktionen ─────────────────────────────────────────────────────────
|
// ── Karten-Aktionen ─────────────────────────────────────────────────────────
|
||||||
const deleteCard = async (id) => {
|
const deleteCard = async (id) => {
|
||||||
if (!confirm('Karte löschen?')) return;
|
if (!confirm('Karte löschen?')) return;
|
||||||
try { await api(`/tools/kanban/cards/${id}`, { method:'DELETE' }); load(); }
|
try { await api(`/tools/kanban/cards/${id}`, { method:'DELETE' }); load(); }
|
||||||
catch (e) { alert(e.message); }
|
catch (e) { toast(e.message, 'error'); }
|
||||||
};
|
};
|
||||||
|
|
||||||
// Position innerhalb einer Spalte
|
const moveCard = async (cardId, colIdOrPos, posOrUndef) => {
|
||||||
const moveCard = async (cardId, colId, newPos) => {
|
// Aufruf-Varianten:
|
||||||
try { await api(`/tools/kanban/cards/${cardId}/move`, { body:{ column_id:colId, position:newPos } }); load(); }
|
// onMove(id, col.id, newPos) → Position innerhalb Spalte
|
||||||
catch (e) { alert(e.message); }
|
// onMove(id, targetColId) → Spalte wechseln, ans Ende
|
||||||
};
|
try {
|
||||||
|
if (posOrUndef !== undefined) {
|
||||||
// In andere Spalte verschieben (Pfeil-Buttons) — ans Ende der Zielspalte
|
await api(`/tools/kanban/cards/${cardId}/move`, { body:{ column_id:colIdOrPos, position:posOrUndef } });
|
||||||
const moveCardToCol = async (cardId, targetColId) => {
|
} else {
|
||||||
if (!targetColId) return;
|
await api(`/tools/kanban/cards/${cardId}/move`, { body:{ column_id:colIdOrPos } });
|
||||||
try { await api(`/tools/kanban/cards/${cardId}/move`, { body:{ column_id:targetColId } }); load(); }
|
}
|
||||||
catch (e) { alert(e.message); }
|
load();
|
||||||
|
} catch (e) { toast(e.message, 'error'); }
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Drag & Drop ─────────────────────────────────────────────────────────────
|
// ── Drag & Drop ─────────────────────────────────────────────────────────────
|
||||||
@@ -297,26 +351,19 @@ export default function Kanban() {
|
|||||||
setDragCardId(cardId);
|
setDragCardId(cardId);
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
};
|
};
|
||||||
|
const onDragEnd = () => { setDragCardId(null); setDragOverCol(null); setDragOverPos(null); };
|
||||||
const onDragEnd = () => {
|
|
||||||
setDragCardId(null); setDragOverCol(null); setDragOverPos(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDragOverCard = (e, colId, pos) => {
|
const onDragOverCard = (e, colId, pos) => {
|
||||||
e.preventDefault();
|
e.preventDefault(); e.stopPropagation();
|
||||||
e.dataTransfer.dropEffect = 'move';
|
e.dataTransfer.dropEffect = 'move';
|
||||||
setDragOverCol(colId); setDragOverPos(pos);
|
setDragOverCol(colId); setDragOverPos(pos);
|
||||||
};
|
};
|
||||||
|
const onDragOverColBg = (e, colId) => {
|
||||||
const onDragOverCol = (e, colId) => {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = 'move';
|
e.dataTransfer.dropEffect = 'move';
|
||||||
setDragOverCol(colId);
|
|
||||||
// ans Ende, wenn über Spalten-Hintergrund (nicht über Karte)
|
|
||||||
const col = columns.find(c => c.id === colId);
|
const col = columns.find(c => c.id === colId);
|
||||||
setDragOverPos(col ? col.cards.length : 0);
|
setDragOverCol(colId); setDragOverPos(col ? col.cards.length : 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDrop = async (e, colId, pos) => {
|
const onDrop = async (e, colId, pos) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!dragCardId) return;
|
if (!dragCardId) return;
|
||||||
@@ -325,32 +372,31 @@ export default function Kanban() {
|
|||||||
try {
|
try {
|
||||||
await api(`/tools/kanban/cards/${dragCardId}/move`, { body:{ column_id:colId, position:targetPos } });
|
await api(`/tools/kanban/cards/${dragCardId}/move`, { body:{ column_id:colId, position:targetPos } });
|
||||||
load();
|
load();
|
||||||
} catch (err) { alert(err.message); }
|
} catch (err) { toast(err.message, 'error'); }
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Render ───────────────────────────────────────────────────────────────────
|
// ── Render ────────────────────────────────────────────────────────────────────
|
||||||
if (loading) return (
|
if (loading) return (
|
||||||
<div style={{ color:'rgba(255,255,255,0.35)', fontFamily:'monospace', fontSize:12, padding:20 }}>
|
<div style={{ padding: mobile ? '14px 14px 90px' : '36px 44px' }}>
|
||||||
Lädt…
|
<div style={{ color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:12 }}>Lädt…</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height:'100%', display:'flex', flexDirection:'column' }}>
|
<div style={{ padding: mobile ? '14px 14px 90px' : '36px 44px', height:'100%', boxSizing:'border-box', display:'flex', flexDirection:'column' }}>
|
||||||
|
|
||||||
{/* ── Header ─────────────────────────────────────────────────────────── */}
|
{/* ── Header ─────────────────────────────────────────────────────────── */}
|
||||||
<div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:16, flexWrap:'wrap' }}>
|
<div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:24, flexWrap:'wrap' }}>
|
||||||
<div style={{ ...S.head, marginBottom:0, flex:1 }}>KANBAN BOARD</div>
|
<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 ? (
|
{addingCol ? (
|
||||||
<div style={{ display:'flex', gap:6 }}>
|
<div style={{ display:'flex', gap:6 }}>
|
||||||
<input
|
<input ref={newColRef} value={newColInput} onChange={e=>setNewColInput(e.target.value)}
|
||||||
ref={newColRef}
|
|
||||||
value={newColInput}
|
|
||||||
onChange={e=>setNewColInput(e.target.value)}
|
|
||||||
onKeyDown={e=>{ if(e.key==='Enter') addColumn(); if(e.key==='Escape'){setAddingCol(false);setNewColInput('');} }}
|
onKeyDown={e=>{ if(e.key==='Enter') addColumn(); if(e.key==='Escape'){setAddingCol(false);setNewColInput('');} }}
|
||||||
placeholder="Spaltenname"
|
placeholder="Spaltenname" style={{ ...S.inp, width: mobile?140:180, fontSize:12 }} />
|
||||||
style={{ ...S.inp, width:160, fontSize:12 }}
|
<button onClick={addColumn} style={S.btn('#4ecdc4', true)}>Hinzufügen</button>
|
||||||
/>
|
|
||||||
<button onClick={addColumn} style={S.btn('#4ecdc4', true)}>+ Hinzufügen</button>
|
|
||||||
<button onClick={()=>{setAddingCol(false);setNewColInput('');}} style={S.btn('#888888', true)}>✕</button>
|
<button onClick={()=>{setAddingCol(false);setNewColInput('');}} style={S.btn('#888888', true)}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -360,76 +406,59 @@ export default function Kanban() {
|
|||||||
|
|
||||||
{/* ── Board ──────────────────────────────────────────────────────────── */}
|
{/* ── Board ──────────────────────────────────────────────────────────── */}
|
||||||
{columns.length === 0 ? (
|
{columns.length === 0 ? (
|
||||||
<div style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:12, padding:'30px 0', textAlign:'center' }}>
|
<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.
|
Noch keine Spalten. Erstelle eine Spalte um loszulegen.
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{
|
<div style={{ display:'flex', gap:14, overflowX:'auto', flex:1,
|
||||||
display:'flex', gap:12, overflowX:'auto', flex:1,
|
paddingBottom:12, scrollbarWidth:'thin', scrollbarColor:'rgba(255,255,255,0.08) transparent' }}>
|
||||||
paddingBottom:8,
|
{columns.map(col => (
|
||||||
// Scrollbar-Styling
|
<div key={col.id}
|
||||||
scrollbarWidth:'thin', scrollbarColor:'rgba(255,255,255,0.1) transparent',
|
onDragOver={e=>onDragOverColBg(e, col.id)}
|
||||||
}}>
|
|
||||||
{columns.map((col, colIdx) => (
|
|
||||||
<div
|
|
||||||
key={col.id}
|
|
||||||
onDragOver={e=>onDragOverCol(e, col.id)}
|
|
||||||
onDrop={e=>onDrop(e, col.id, col.cards.length)}
|
onDrop={e=>onDrop(e, col.id, col.cards.length)}
|
||||||
style={{
|
style={{
|
||||||
background: dragOverCol===col.id && dragCardId ? 'rgba(78,205,196,0.04)' : 'rgba(255,255,255,0.03)',
|
...S.card,
|
||||||
border: `1px solid ${dragOverCol===col.id && dragCardId ? 'rgba(78,205,196,0.3)' : 'rgba(255,255,255,0.07)'}`,
|
minWidth: mobile ? 260 : 280,
|
||||||
borderRadius:12, padding:12,
|
maxWidth: 320,
|
||||||
minWidth:260, maxWidth:300, width:280,
|
width: mobile ? 260 : 280,
|
||||||
flexShrink:0, display:'flex', flexDirection:'column',
|
flexShrink:0,
|
||||||
transition:'border-color 0.15s, background 0.15s',
|
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)'}`,
|
||||||
{/* Spalten-Header */}
|
transition:'border-color 0.15s',
|
||||||
<div style={{ display:'flex', alignItems:'center', gap:6, marginBottom:12 }}>
|
}}>
|
||||||
<ColumnTitle col={col} onRename={renameColumn} onDelete={deleteColumn}/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Karten */}
|
<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' }}>
|
<div style={{ flex:1, overflowY:'auto', scrollbarWidth:'thin', scrollbarColor:'rgba(255,255,255,0.08) transparent' }}>
|
||||||
{col.cards.map((card, cardIdx) => (
|
{col.cards.map((card, cardIdx) => (
|
||||||
<div
|
<div key={card.id}
|
||||||
key={card.id}
|
onDragOver={e=>onDragOverCard(e, col.id, cardIdx)}
|
||||||
onDragOver={e=>{ e.stopPropagation(); onDragOverCard(e, col.id, cardIdx); }}
|
onDrop={e=>{ e.stopPropagation(); onDrop(e, col.id, cardIdx); }}>
|
||||||
onDrop={e=>{ e.stopPropagation(); onDrop(e, col.id, cardIdx); }}
|
|
||||||
style={{ position:'relative' }}
|
|
||||||
>
|
|
||||||
{/* Drop-Indikator oberhalb der Karte */}
|
|
||||||
{dragCardId && dragOverCol===col.id && dragOverPos===cardIdx && (
|
{dragCardId && dragOverCol===col.id && dragOverPos===cardIdx && (
|
||||||
<div style={{ height:3, background:'#4ecdc4', borderRadius:2, marginBottom:4 }}/>
|
<div style={{ height:3,background:col.color||'#4ecdc4',borderRadius:2,marginBottom:4,opacity:0.7 }}/>
|
||||||
)}
|
)}
|
||||||
<KanbanCard
|
<KanbanCard
|
||||||
card={card}
|
card={card} col={col} allCols={columns}
|
||||||
col={col}
|
|
||||||
allCols={columns}
|
|
||||||
onEdit={card=>setModal({ card, columnId:col.id })}
|
onEdit={card=>setModal({ card, columnId:col.id })}
|
||||||
onDelete={deleteCard}
|
onDelete={deleteCard}
|
||||||
onMove={moveCard}
|
onMove={moveCard}
|
||||||
onMoveCol={moveCardToCol}
|
|
||||||
isDragging={dragCardId===card.id}
|
isDragging={dragCardId===card.id}
|
||||||
dragHandlers={{
|
dragHandlers={{ draggable:true, onDragStart:e=>onDragStart(e,card.id), onDragEnd }}
|
||||||
draggable: true,
|
|
||||||
onDragStart: e => onDragStart(e, card.id),
|
|
||||||
onDragEnd,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Drop-Zone ans Ende der Spalte */}
|
|
||||||
{dragCardId && dragOverCol===col.id && dragOverPos===col.cards.length && (
|
{dragCardId && dragOverCol===col.id && dragOverPos===col.cards.length && (
|
||||||
<div style={{ height:3, background:'#4ecdc4', borderRadius:2, marginTop:2 }}/>
|
<div style={{ height:3,background:col.color||'#4ecdc4',borderRadius:2,marginTop:2,opacity:0.7 }}/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Karte hinzufügen */}
|
{/* Karte hinzufügen */}
|
||||||
<button
|
<button onClick={()=>setModal({ card:null, columnId:col.id })}
|
||||||
onClick={()=>setModal({ card:null, columnId:col.id })}
|
style={{ ...S.btn(col.color||'#4ecdc4', true), width:'100%', marginTop:10, textAlign:'center' }}>
|
||||||
style={{ ...S.btn('#4ecdc4', true), width:'100%', marginTop:10, textAlign:'center' }}>
|
|
||||||
+ Karte
|
+ Karte
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -442,8 +471,10 @@ export default function Kanban() {
|
|||||||
<CardModal
|
<CardModal
|
||||||
card={modal.card}
|
card={modal.card}
|
||||||
columnId={modal.columnId}
|
columnId={modal.columnId}
|
||||||
|
columns={columns}
|
||||||
onSave={()=>{ setModal(null); load(); }}
|
onSave={()=>{ setModal(null); load(); }}
|
||||||
onClose={()=>setModal(null)}
|
onClose={()=>setModal(null)}
|
||||||
|
toast={toast}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user