ToDo-Board: BoardModal, Roadmap, Wünsche, Unread-Polling

This commit is contained in:
2026-05-28 18:26:23 +02:00
parent 2041168ca1
commit afae09dd6e
4 changed files with 269 additions and 1615 deletions

View File

@@ -284,6 +284,28 @@ for (const [k, v] of loginDefaults) {
db.prepare('INSERT INTO admin_settings (key, value) VALUES (?, ?)').run(k, v);
}
// ── Ideen-Board ───────────────────────────────────────────────────────────────
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='board_items'").get()) {
db.exec(`
CREATE TABLE board_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL CHECK(type IN ('roadmap','wish')),
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT NULL
)
`);
}
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='board_reads'").get()) {
db.exec(`
CREATE TABLE board_reads (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
last_read DATETIME DEFAULT NULL
)
`);
}
// ── Link-Liste ────────────────────────────────────────────────────────────────
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='link_list'").get()) {
db.exec(`

View File

@@ -67,7 +67,6 @@ router.put('/note', authenticate, (req, res) => {
res.json({ success: true, updated_at: new Date().toISOString() });
});
module.exports = router;
// ── Statistiken ───────────────────────────────────────────────────────────────
router.get('/stats', authenticate, (req, res) => {
@@ -123,3 +122,71 @@ router.get('/stats', authenticate, (req, res) => {
res.json({ totalOrders, bezahltOrders, totalRevenue, offeneRevenue, ordersThisMonth, totalCalcs, byStatus });
});
// ── Ideen-Board ───────────────────────────────────────────────────────────────
router.get('/board', authenticate, (req, res) => {
const u = uid(req);
const items = db.prepare(`
SELECT b.*, us.username as author
FROM board_items b JOIN users us ON us.id=b.user_id
ORDER BY b.type ASC, b.created_at DESC
`).all();
const lastRead = db.prepare('SELECT last_read FROM board_reads WHERE user_id=?').get(u)?.last_read;
const unread = lastRead
? db.prepare("SELECT COUNT(*) c FROM board_items WHERE created_at > ? AND user_id != ?").get(lastRead, u).c
: db.prepare('SELECT COUNT(*) c FROM board_items WHERE user_id != ?').get(u).c;
res.json({ items, unread });
});
router.get('/board/unread', authenticate, (req, res) => {
const u = uid(req);
const lastRead = db.prepare('SELECT last_read FROM board_reads WHERE user_id=?').get(u)?.last_read;
const unread = lastRead
? db.prepare("SELECT COUNT(*) c FROM board_items WHERE created_at > ? AND user_id != ?").get(lastRead, u).c
: db.prepare('SELECT COUNT(*) c FROM board_items WHERE user_id != ?').get(u).c;
res.json({ unread });
});
router.post('/board/read', authenticate, (req, res) => {
const u = uid(req);
db.prepare(`INSERT OR REPLACE INTO board_reads (user_id, last_read) VALUES (?, datetime('now','localtime'))`).run(u);
res.json({ ok: true });
});
router.post('/board', authenticate, (req, res) => {
const u = uid(req);
const { type, title, description = '' } = req.body;
if (!['roadmap','wish'].includes(type)) return res.status(400).json({ error: 'Ungültiger Typ' });
if (type === 'roadmap' && req.user.role !== 'admin') return res.status(403).json({ error: 'Nur Admins' });
if (!title?.trim()) return res.status(400).json({ error: 'Titel erforderlich' });
const r = db.prepare(`INSERT INTO board_items (type, user_id, title, description, created_at) VALUES (?,?,?,?,datetime('now','localtime'))`)
.run(type, u, title.trim(), description.trim());
res.json({ ...db.prepare('SELECT * FROM board_items WHERE id=?').get(r.lastInsertRowid),
author: req.user.username });
});
router.put('/board/:id', authenticate, (req, res) => {
const item = db.prepare('SELECT * FROM board_items WHERE id=?').get(req.params.id);
if (!item) return res.status(404).json({ error: 'Nicht gefunden' });
const isAdmin = req.user.role === 'admin';
const isOwn = item.user_id === uid(req);
if (!isAdmin && (!isOwn || item.type === 'roadmap')) return res.status(403).json({ error: 'Kein Zugriff' });
const { title, description } = req.body;
db.prepare('UPDATE board_items SET title=?, description=? WHERE id=?')
.run(title ?? item.title, description ?? item.description, item.id);
res.json({ ...db.prepare('SELECT * FROM board_items WHERE id=?').get(item.id), author: req.user.username });
});
router.delete('/board/:id', authenticate, (req, res) => {
const item = db.prepare('SELECT * FROM board_items WHERE id=?').get(req.params.id);
if (!item) return res.status(404).json({ error: 'Nicht gefunden' });
const isAdmin = req.user.role === 'admin';
const isOwn = item.user_id === uid(req);
if (!isAdmin && !isOwn) return res.status(403).json({ error: 'Kein Zugriff' });
db.prepare('DELETE FROM board_items WHERE id=?').run(item.id);
res.json({ ok: true });
});
module.exports = router;

View File

@@ -821,11 +821,162 @@ function PushoverSettings({ toast }) {
);
}
function Dashboard({ toast, mobile, setActive, unreadMsgs=0 }) {
// ── Ideen-Board Modal ─────────────────────────────────────────────────────────
function BoardModal({ user, toast, onClose, onRead }) {
const isAdmin = user?.role === 'admin';
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [form, setForm] = useState({ type:'wish', title:'', description:'' });
const [editId, setEditId] = useState(null);
const [editForm,setEditForm]= useState({});
const isMobile = window.innerWidth < 768;
useEffect(()=>{
api('/dashboard/board').then(d=>{ setItems(d.items||[]); setLoading(false); }).catch(()=>setLoading(false));
api('/dashboard/board/read',{method:'POST'}).then(()=>onRead()).catch(()=>{});
},[]);
const add = async () => {
if (!form.title.trim()) { toast('Titel erforderlich','error'); return; }
try {
const r = await api('/dashboard/board',{body:form});
setItems(p=>[r,...p]); setForm({type:'wish',title:'',description:''});
toast('Hinzugefügt ✓');
} catch(e){ toast(e.message,'error'); }
};
const save = async id => {
try {
const r = await api(`/dashboard/board/${id}`,{method:'PUT',body:editForm});
setItems(p=>p.map(i=>i.id===id?r:i)); setEditId(null); toast('Gespeichert ✓');
} catch(e){ toast(e.message,'error'); }
};
const del = async id => {
try { await api(`/dashboard/board/${id}`,{method:'DELETE'}); setItems(p=>p.filter(i=>i.id!==id)); }
catch(e){ toast(e.message,'error'); }
};
const roadmap = items.filter(i=>i.type==='roadmap');
const wishes = items.filter(i=>i.type==='wish');
const ItemRow = ({item}) => {
const canEdit = isAdmin || item.user_id === user?.id;
const isEditing = editId === item.id;
return (
<div style={{padding:'10px 0',borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
{isEditing ? (
<div>
<input value={editForm.title} onChange={e=>setEditForm(p=>({...p,title:e.target.value}))}
style={{...S.inp,marginBottom:6,fontSize:13}}/>
<input value={editForm.description} onChange={e=>setEditForm(p=>({...p,description:e.target.value}))}
placeholder="Beschreibung (optional)" style={{...S.inp,marginBottom:8,fontSize:12}}/>
<div style={{display:'flex',gap:6}}>
<button onClick={()=>save(item.id)} style={{...S.btn('#4ecdc4'),fontSize:11,padding:'5px 12px'}}></button>
<button onClick={()=>setEditId(null)} style={{...S.btn('#ff6b9d',true),fontSize:11,padding:'5px 10px'}}></button>
</div>
</div>
) : (
<div style={{display:'flex',alignItems:'flex-start',gap:8}}>
<div style={{flex:1,minWidth:0}}>
<div style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700}}>{item.title}</div>
{item.description && <div style={{color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:11,marginTop:2}}>{item.description}</div>}
<div style={{color:'rgba(255,255,255,0.2)',fontFamily:'monospace',fontSize:9,marginTop:3}}>
{item.author} · {item.created_at ? new Date(item.created_at.replace(' ','T')).toLocaleDateString('de-DE') : ''}
</div>
</div>
{canEdit && (
<div style={{display:'flex',gap:4,flexShrink:0}}>
<button onClick={()=>{setEditId(item.id);setEditForm({title:item.title,description:item.description});}}
style={{...S.btn('#4ecdc4',true),padding:'3px 8px',fontSize:11}}></button>
<button onClick={()=>del(item.id)} style={{...S.btn('#ff6b9d',true),padding:'3px 8px',fontSize:11}}></button>
</div>
)}
</div>
)}
</div>
);
};
return (
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.85)',zIndex:7000,
display:'flex',alignItems:isMobile?'flex-end':'center',justifyContent:'center',padding:isMobile?0:24}}
onClick={e=>e.target===e.currentTarget&&onClose()}>
<div style={{background:'#1a1a1e',borderRadius:isMobile?'16px 16px 0 0':16,width:'100%',maxWidth:520,
border:'1px solid rgba(255,255,255,0.12)',display:'flex',flexDirection:'column',
maxHeight:isMobile?'85vh':'80vh',overflow:'hidden'}}>
{/* Header */}
<div style={{padding:'16px 20px',borderBottom:'1px solid rgba(255,255,255,0.08)',flexShrink:0,
display:'flex',justifyContent:'space-between',alignItems:'center'}}>
{isMobile&&<div style={{width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,position:'absolute',top:8,left:'50%',transform:'translateX(-50%)'}}/>}
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:14,fontWeight:700}}>📋 Ideen & Roadmap</div>
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18}}></button>
</div>
<div style={{overflowY:'auto',flex:1,padding:'0 20px'}}>
{/* Roadmap */}
<div style={{padding:'14px 0 4px'}}>
<div style={{...S.head,marginBottom:6}}>🗺 ROADMAP{isAdmin&&<span style={{color:'rgba(255,255,255,0.3)',fontWeight:400}}> (Admin)</span>}</div>
{loading ? <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11}}>Lädt</div>
: roadmap.length===0 ? <div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:11}}>Noch keine Einträge.</div>
: roadmap.map(i=><ItemRow key={i.id} item={i}/>)}
{isAdmin && (
<div style={{marginTop:10}}>
<input value={form.type==='roadmap'?form.title:''} placeholder="+ Roadmap-Eintrag hinzufügen…"
onFocus={()=>setForm(f=>({...f,type:'roadmap'}))}
onChange={e=>setForm(f=>({...f,title:e.target.value,type:'roadmap'}))}
onKeyDown={e=>e.key==='Enter'&&form.type==='roadmap'&&add()}
style={{...S.inp,fontSize:13,marginBottom:form.type==='roadmap'&&form.title?6:0}}/>
{form.type==='roadmap'&&form.title&&(
<div style={{display:'flex',gap:6}}>
<input value={form.description} placeholder="Beschreibung (optional)"
onChange={e=>setForm(f=>({...f,description:e.target.value}))}
style={{...S.inp,fontSize:12,flex:1}}/>
<button onClick={add} style={{...S.btn('#4ecdc4'),fontSize:11,padding:'5px 12px'}}>+</button>
</div>
)}
</div>
)}
</div>
{/* Wishes */}
<div style={{padding:'14px 0 20px',borderTop:'1px solid rgba(255,255,255,0.06)',marginTop:8}}>
<div style={{...S.head,marginBottom:6}}>💡 WÜNSCHE & IDEEN</div>
{loading ? null : wishes.length===0
? <div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:11}}>Noch keine Wünsche.</div>
: wishes.map(i=><ItemRow key={i.id} item={i}/>)}
<div style={{marginTop:10}}>
<input value={form.type==='wish'?form.title:''} placeholder="+ Wunsch oder Idee einreichen…"
onFocus={()=>setForm(f=>({...f,type:'wish'}))}
onChange={e=>setForm(f=>({...f,title:e.target.value,type:'wish'}))}
onKeyDown={e=>e.key==='Enter'&&form.type==='wish'&&add()}
style={{...S.inp,fontSize:13,marginBottom:form.type==='wish'&&form.title?6:0}}/>
{form.type==='wish'&&form.title&&(
<div style={{display:'flex',gap:6}}>
<input value={form.description} placeholder="Beschreibung (optional)"
onChange={e=>setForm(f=>({...f,description:e.target.value}))}
style={{...S.inp,fontSize:12,flex:1}}/>
<button onClick={add} style={{...S.btn('#4ecdc4'),fontSize:11,padding:'5px 12px'}}>+</button>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}
function Dashboard({ toast, mobile, setActive, unreadMsgs=0, unreadBoard=0, user }) {
const [now, setNow] = useState(new Date());
const [showBoard, setShowBoard] = useState(false);
const [boardUnread, setBoardUnread] = useState(unreadBoard);
useEffect(()=>{ setBoardUnread(unreadBoard); },[unreadBoard]);
useEffect(() => { const t = setInterval(() => setNow(new Date()), 30000); return () => clearInterval(t); }, []);
return (
<div style={{ padding: mobile ? '14px 12px 90px' : '36px 44px', maxWidth:1100 }}>
{showBoard && <BoardModal user={user} toast={toast} onClose={()=>setShowBoard(false)} onRead={()=>setBoardUnread(0)}/>}
<div style={{ marginBottom:16, display:'flex', alignItems:'flex-start', justifyContent:'space-between' }}>
<div>
<h1 style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:mobile ? 17 : 22, margin:0 }}>Dashboard</h1>
@@ -846,10 +997,22 @@ function Dashboard({ toast, mobile, setActive, unreadMsgs=0 }) {
<BestellStats/>
<TodoList toast={toast} />
<Notepad toast={toast} />
<div style={{ marginTop:24, textAlign:'center',
color:'rgba(255,255,255,0.35)', fontSize:9, fontFamily:'monospace', letterSpacing:1 }}>
{typeof __BUILD_TIME__ !== 'undefined' && __BUILD_TIME__
? `Letzte Versionierung: ${__BUILD_TIME__}` : null}
<div style={{ marginTop:24, display:'flex', alignItems:'center', justifyContent:'center', gap:12 }}>
<div style={{ color:'rgba(255,255,255,0.35)', fontSize:9, fontFamily:'monospace', letterSpacing:1 }}>
{typeof __BUILD_TIME__ !== 'undefined' && __BUILD_TIME__
? `Letzte Versionierung: ${__BUILD_TIME__}` : null}
</div>
<button onClick={()=>setShowBoard(true)} style={{
position:'relative', background:'rgba(255,255,255,0.04)',
border:'1px solid rgba(255,255,255,0.1)', borderRadius:7,
color:'rgba(255,255,255,0.45)', cursor:'pointer',
padding:'4px 10px', fontSize:9, fontFamily:'monospace', letterSpacing:1 }}>
📋 ToDo
{boardUnread > 0 && (
<span style={{ position:'absolute', top:-3, right:-3, width:7, height:7,
borderRadius:'50%', background:'#4ecdc4', boxShadow:'0 0 5px #4ecdc4' }}/>
)}
</button>
</div>
</div>
);
@@ -1607,7 +1770,8 @@ export default function App() {
const [toastMsg,setToastMsg]=useState({msg:'',type:'success'});
const [updateInfo,setUpdateInfo]=useState(null);
const [showUpd,setShowUpd]=useState(false);
const [unreadMsgs,setUnreadMsgs]=useState(0);
const [unreadMsgs, setUnreadMsgs] = useState(0);
const [unreadBoard, setUnreadBoard] = useState(0);
const [authChecked, setAuthChecked] = useState(false);
useEffect(()=>{
@@ -1640,6 +1804,14 @@ export default function App() {
}).catch(()=>{});
},[user]);
useEffect(()=>{
if(!user)return;
const poll=()=>api('/dashboard/board/unread').then(d=>setUnreadBoard(d.unread||0)).catch(()=>{});
poll();
const t=setInterval(poll,60*1000);
return()=>clearInterval(t);
},[user]);
useEffect(()=>{
if(!user)return;
const poll=()=>api('/tools/nachrichten/unread').then(d=>setUnreadMsgs(d.count||0)).catch(()=>{});
@@ -1687,7 +1859,7 @@ export default function App() {
unreadMsgs={unreadMsgs}/>
)}
<main style={mainStyle}>
{active==='dashboard' && <Dashboard toast={toast} mobile={mobile} setActive={setActive} unreadMsgs={unreadMsgs}/>}
{active==='dashboard' && <Dashboard toast={toast} mobile={mobile} setActive={setActive} unreadMsgs={unreadMsgs} unreadBoard={unreadBoard} user={user}/>}
{active==='admin' && <AdminPanel toast={toast} mobile={mobile} user={user}/>}
{activeTool && <activeTool.component toast={toast} mobile={mobile} setActive={setActive}/>}
</main>

File diff suppressed because it is too large Load Diff