diff --git a/backend/src/db.js b/backend/src/db.js index 016590d..c2e667c 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -258,6 +258,8 @@ if (!userCols2.includes('hidden')) db.exec("ALTER TABLE users ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0"); if (!userCols2.includes('chat_active_at')) db.exec("ALTER TABLE users ADD COLUMN chat_active_at DATETIME DEFAULT NULL"); +if (!userCols2.includes('hidden_tools')) + db.exec("ALTER TABLE users ADD COLUMN hidden_tools TEXT NOT NULL DEFAULT '[]'"); // ── Login-Sicherheit ────────────────────────────────────────────────────────── // Spalten für Account-Lockout in users-Tabelle diff --git a/backend/src/index.js b/backend/src/index.js index 7127e90..dc65262 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -302,6 +302,8 @@ async function runScheduler() { mqttClient.publishPresence(); mqttClient.publishMediaAnfragen(); mqttClient.publishDruckStatistik(); + mqttClient.publishPushErinnerungen(); + mqttClient.publishKanban(); try { const now = db.prepare("SELECT datetime('now','localtime') as t").get().t; const due = db.prepare(` diff --git a/backend/src/mqtt.js b/backend/src/mqtt.js index 3a4646d..e3d2a76 100644 --- a/backend/src/mqtt.js +++ b/backend/src/mqtt.js @@ -288,6 +288,28 @@ function publishDiscovery() { availability_topic: AVAILABILITY_TOPIC, device: DEVICE, }], + // ── Dashboard ───────────────────────────────────────────────────────── + ['sensor', 'dickendock_dashboard_push_erinnerungen', { + name: 'Dashboard Push-Erinnerungen offen', + unique_id: 'dickendock_dashboard_push_erinnerungen', + state_topic: `${BASE_TOPIC}/dashboard/push_erinnerungen`, + json_attributes_topic: `${BASE_TOPIC}/dashboard/push_erinnerungen_attributes`, + icon: 'mdi:bell-ring-outline', + unit_of_measurement: 'Erinnerungen', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + + // ── Kanban ──────────────────────────────────────────────────────────── + ['sensor', 'dickendock_kanban_karten', { + name: 'Kanban Karten offen', + unique_id: 'dickendock_kanban_karten', + state_topic: `${BASE_TOPIC}/kanban/karten`, + json_attributes_topic: `${BASE_TOPIC}/kanban/karten_attributes`, + icon: 'mdi:view-column-outline', + unit_of_measurement: 'Karten', state_class: 'measurement', + availability_topic: AVAILABILITY_TOPIC, device: DEVICE, + }], + // ── System ──────────────────────────────────────────────────────────── ['sensor', 'dickendock_system_online', { name: 'System Nutzer online', @@ -542,6 +564,43 @@ function publishDruckStatistik() { pub('druck/bestellungen_gesamt', allOrders.length); } +// Wird jede Minute vom Scheduler aufgerufen — offene (noch nicht versendete, +// in der Zukunft liegende) Push-Erinnerungen aus dem Dashboard, systemweit +function publishPushErinnerungen() { + if (!client?.connected) return; + const offen = db.prepare(` + SELECT s.message, s.scheduled_at, u.username + FROM push_schedules s JOIN users u ON u.id = s.user_id + WHERE s.sent = 0 AND s.scheduled_at > datetime('now','localtime') + ORDER BY s.scheduled_at ASC + `).all(); + client.publish(`${BASE_TOPIC}/dashboard/push_erinnerungen`, String(offen.length), { retain: true }); + client.publish(`${BASE_TOPIC}/dashboard/push_erinnerungen_attributes`, JSON.stringify({ + erinnerungen: offen, + }), { retain: true }); +} + +// Wird jede Minute vom Scheduler aufgerufen — alle Kanban-Karten über alle +// Nutzer-Boards hinweg (Kanban ist pro Nutzer, hier systemweit aggregiert) +function publishKanban() { + if (!client?.connected) return; + const karten = db.prepare(` + SELECT k.title, k.priority, col.title AS spalte, u.username + FROM kanban_cards k + JOIN kanban_columns col ON col.id = k.column_id + JOIN users u ON u.id = k.user_id + ORDER BY u.username ASC, col.position ASC, k.position ASC + `).all(); + const proNutzer = {}; + for (const k of karten) proNutzer[k.username] = (proNutzer[k.username] || 0) + 1; + + client.publish(`${BASE_TOPIC}/kanban/karten`, String(karten.length), { retain: true }); + client.publish(`${BASE_TOPIC}/kanban/karten_attributes`, JSON.stringify({ + karten, + pro_nutzer: proNutzer, + }), { retain: true }); +} + // Wird von index.js gesetzt, um Zirkel-Requires zu vermeiden function setKoepiCheckHandler(fn) { koepiCheckHandler = fn; } function setMediaAckHandler(fn) { mediaAckHandler = fn; } @@ -553,6 +612,8 @@ module.exports = { publishPresence, publishMediaAnfragen, publishDruckStatistik, + publishPushErinnerungen, + publishKanban, setKoepiCheckHandler, setMediaAckHandler, setMediaAckAllHandler, diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 148752a..3030dec 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -32,7 +32,7 @@ router.post('/restore', authenticate, requireAdmin, upload.single('database'), ( // GET /api/admin/users – alle Benutzer mit Statistiken router.get('/users', authenticate, requireAdmin, (req, res) => { const users = db.prepare(` - SELECT u.id, u.username, u.role, u.created_at, u.last_active_at, u.hidden, + SELECT u.id, u.username, u.role, u.created_at, u.last_active_at, u.hidden, u.hidden_tools, CASE WHEN p.user_id IS NOT NULL THEN 1 ELSE 0 END as has_pushover FROM users u LEFT JOIN pushover_settings p ON p.user_id = u.id @@ -47,7 +47,9 @@ router.get('/users', authenticate, requireAdmin, (req, res) => { const files = db.prepare('SELECT COUNT(*) c FROM files WHERE user_id=?').get(u.id).c; const noteLen = (db.prepare('SELECT content FROM notes WHERE user_id=?').get(u.id)?.content || '').length; const icals = db.prepare('SELECT COUNT(*) c FROM calendar_feeds WHERE user_id=?').get(u.id).c; - return { ...u, archiv, bestellung, snippets_c, todos, links, files, icals, noteLen }; + let hidden_tools = []; + try { hidden_tools = JSON.parse(u.hidden_tools || '[]'); } catch {} + return { ...u, hidden_tools, archiv, bestellung, snippets_c, todos, links, files, icals, noteLen }; }); res.json(stats); }); @@ -170,6 +172,18 @@ router.put('/users/:id/hidden', authenticate, requireAdmin, (req, res) => { res.json({ hidden: newHidden }); }); +// PUT /api/admin/users/:id/hidden-tools – Sidebar-Sichtbarkeit für einen User setzen +// body: { hidden_tools: ['schocken', 'paywallkiller', ...] } (tool-ids aus toolRegistry.js) +router.put('/users/:id/hidden-tools', authenticate, requireAdmin, (req, res) => { + const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.params.id); + if (!user) return res.status(404).json({ error: 'Nicht gefunden' }); + const { hidden_tools } = req.body; + if (!Array.isArray(hidden_tools)) return res.status(400).json({ error: 'hidden_tools muss ein Array sein' }); + const cleaned = [...new Set(hidden_tools.filter(t => typeof t === 'string'))]; + db.prepare('UPDATE users SET hidden_tools=? WHERE id=?').run(JSON.stringify(cleaned), user.id); + res.json({ hidden_tools: cleaned }); +}); + // Test-Pushover an beliebigen User senden router.post('/users/:id/test-push', authenticate, requireAdmin, async (req, res) => { const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.params.id); diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 47f03cb..59976bc 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -166,4 +166,14 @@ router.put('/preferences', authenticate, (req, res) => { res.json(merged); }); +// ── GET /api/auth/hidden-tools ──────────────────────────────────────────────── +// Liefert die vom Admin für diesen Nutzer ausgeblendeten Sidebar-Bereiche. +// Bewusst NICHT über /preferences (das ist selbst-editierbar) — Sichtbarkeit +// darf nur der Admin ändern. +router.get('/hidden-tools', authenticate, (req, res) => { + const user = db.prepare('SELECT hidden_tools FROM users WHERE id=?').get(req.user.id); + try { res.json(JSON.parse(user?.hidden_tools || '[]')); } + catch { res.json([]); } +}); + module.exports = router; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 63e8ead..ea2a117 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -155,7 +155,7 @@ function Login({ onLogin }) { // ── Update Modal ────────────────────────────────────────────────────────────── // ── Desktop Sidebar ─────────────────────────────────────────────────────────── -function Sidebar({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns=0, whiteboardUnread=0, isAdmin=false }) { +function Sidebar({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns=0, whiteboardUnread=0, isAdmin=false, hiddenTools=[] }) { const [appsOpen, setAppsOpen] = useState(true); const [collapsed, setCollapsed] = useState(false); const [collapsedGroups, setCollapsedGroups] = useState({}); @@ -255,7 +255,7 @@ function Sidebar({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns {!collapsed && Apps} {!collapsed && } - {appsOpen && getGroupedTools(isAdmin).map(([group, tools]) => { + {appsOpen && getGroupedTools(isAdmin, hiddenTools).map(([group, tools]) => { const isGroupCollapsed = !!collapsedGroups[group]; return (
@@ -296,7 +296,7 @@ function Sidebar({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns } // ── Mobile Bottom Navigation ────────────────────────────────────────────────── -function BottomNav({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns=0, whiteboardUnread=0, isAdmin=false }) { +function BottomNav({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns=0, whiteboardUnread=0, isAdmin=false, hiddenTools=[] }) { const [menuOpen, setMenuOpen] = useState(false); const [appsOpen, setAppsOpen] = useState(false); const [mobileCollapsedGroups, setMobileCollapsedGroups] = useState({}); @@ -360,7 +360,7 @@ function BottomNav({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTur {/* Apps Sheet mit Gruppenüberschriften */} setAppsOpen(false)}>
- {getGroupedTools(user?.role==='admin').map(([group, tools]) => { + {getGroupedTools(user?.role==='admin', hiddenTools).map(([group, tools]) => { const isGrpCollapsed = !!mobileCollapsedGroups[group]; return (
@@ -2380,6 +2380,8 @@ function UserManagement({ toast }) { const [resetId, setResetId] = useState(null); const [resetPw, setResetPw] = useState(''); const [delId, setDelId] = useState(null); + const [visId, setVisId] = useState(null); + const [visSelection, setVisSelection] = useState([]); const { confirm, ConfirmDialog } = useConfirm(); const load = () => { @@ -2411,6 +2413,24 @@ function UserManagement({ toast }) { } catch(e) { toast(e.message,'error'); } }; + const openVisibility = (u) => { + setVisId(u.id); + setVisSelection(u.hidden_tools || []); + }; + + const toggleVisTool = (toolId) => { + setVisSelection(prev => prev.includes(toolId) ? prev.filter(t=>t!==toolId) : [...prev, toolId]); + }; + + const saveVisibility = async () => { + try { + await api(`/admin/users/${visId}/hidden-tools`,{method:'PUT',body:{hidden_tools:visSelection}}); + setUsers(p=>p.map(x=>x.id===visId?{...x,hidden_tools:visSelection}:x)); + toast('Sichtbarkeit gespeichert'); + setVisId(null); + } catch(e) { toast(e.message,'error'); } + }; + return (
@@ -2448,6 +2468,51 @@ function UserManagement({ toast }) {
)} + {/* Sidebar-Sichtbarkeit Modal */} + {visId && (() => { + const grouped = {}; + for (const t of TOOLS) { + const g = t.group || 'Allgemein'; + if (!grouped[g]) grouped[g] = []; + grouped[g].push(t); + } + const target = users.find(u=>u.id===visId); + return ( +
+
+
+ Sichtbare Bereiche{target ? ` — ${target.username}` : ''} +
+
+ Angehakt = ausgeblendet für diesen Nutzer +
+ {Object.entries(grouped).map(([group, tools]) => ( +
+
{group}
+ {tools.map(t => ( + + ))} +
+ ))} +
+ + +
+
+
+ ); + })()} + {/* Benutzerliste */} {loading &&
Lädt…
} @@ -2508,6 +2573,11 @@ function UserManagement({ toast }) { )} +