Implementierung Wissensmanagement

Dieser Commit ist enthalten in:
HG
2025-12-30 22:49:56 +00:00
committet von Server Deploy
Ursprung 9bf298c26b
Commit 395598c2b0
51 geänderte Dateien mit 7598 neuen und 32 gelöschten Zeilen

Datei anzeigen

@ -1,6 +1,182 @@
TASKMATE - CHANGELOG TASKMATE - CHANGELOG
==================== ====================
================================================================================
30.12.2025 - BUGFIX: Login-Problem behoben (Sofort-Logout nach Login)
================================================================================
PROBLEM
--------------------------------------------------------------------------------
Nach erfolgreichem Login wurde der Benutzer sofort wieder zum Login-Screen
zurückgeleitet. Dies war ein Race-Condition-Problem mit mehrfachen WebSocket-
Verbindungen und fehlenden Token-Prüfungen.
URSACHE
--------------------------------------------------------------------------------
1. syncManager.connect() wurde doppelt aufgerufen (einmal in sync.js Event-
Listener und einmal in app.js initializeApp())
2. Der Socket-Error-Handler in sync.js löste auth:logout aus OHNE zu prüfen,
ob sich der Token in der Zwischenzeit geändert hatte
LÖSUNG
--------------------------------------------------------------------------------
1. Doppelten syncManager.connect() Aufruf entfernt (nur noch in app.js)
2. Socket-Error-Handler prüft nun, ob ein gültiger Token existiert, bevor
logout ausgelöst wird
3. syncManager.connect() prüft jetzt ob Socket bereits existiert (nicht nur
ob er verbunden ist), um Race Conditions während des Verbindungsaufbaus
zu verhindern
BETROFFENE DATEIEN
--------------------------------------------------------------------------------
- frontend/js/sync.js: Doppelten Connect-Aufruf entfernt, Token-Check hinzugefügt,
verbesserte Socket-Existenz-Prüfung
- frontend/sw.js: Cache-Version auf 151 erhöht
================================================================================
30.12.2025 - UI-REDESIGN: Wissensmanagement-Tab komplett überarbeitet
================================================================================
NEUES LAYOUT: SIDEBAR + HAUPTBEREICH
--------------------------------------------------------------------------------
Der Wissensmanagement-Tab wurde komplett überarbeitet mit einem neuen
2-Spalten-Layout für bessere Übersicht und Bedienbarkeit.
NEUE FEATURES
--------------------------------------------------------------------------------
- Sidebar-Layout: Kategorien links, Einträge rechts - beide gleichzeitig sichtbar
- Drag & Drop Sortierung: Kategorien UND Einträge per Drag & Drop sortierbar
- Kompakte Ansicht: Einträge zeigen nur Titel, Details per Klick aufklappbar
- Aktive Kategorie: Farbiger linker Rand zeigt die ausgewählte Kategorie
- Indikatoren: Icons zeigen auf einen Blick ob Link, Notizen oder Anhänge vorhanden
- Responsive Design: Auf Mobile werden Kategorien als horizontale Chips angezeigt
UI-VERBESSERUNGEN
--------------------------------------------------------------------------------
- Aufklappbare Einträge (▶/▼) für bessere Platznutzung
- Drag-Handles (⋮⋮) für intuitive Sortierung
- Hover-Aktionen (Bearbeiten, Löschen) erscheinen bei Mausbewegung
- Sanfte Animationen für Expand/Collapse
BETROFFENE DATEIEN
--------------------------------------------------------------------------------
- frontend/index.html: Komplette Neustrukturierung des Knowledge-Views
(Sidebar + Main Layout, Mobile Chips)
- frontend/css/knowledge.css: Komplett neu geschrieben für das neue Layout
(Grid-System, Sidebar, kompakte Einträge, Drag & Drop Styles)
- frontend/js/knowledge.js: Komplett überarbeitet mit neuem Rendering,
Drag & Drop Handlers, Expand/Collapse Logik
- frontend/sw.js: Cache-Version auf 149 erhöht
================================================================================
30.12.2025 - VERBESSERUNG: Globale Suche für Wissensmanagement
================================================================================
ÄNDERUNG: SUCHFELD IN HEADER INTEGRIERT
--------------------------------------------------------------------------------
Die Suchfunktion für den Wissensmanagement-Tab wurde vom Tab selbst in das
globale Suchfeld im Header verschoben. Wenn der Benutzer auf dem "Wissen"-Tab
ist, durchsucht das Header-Suchfeld automatisch die Wissensdatenbank.
DETAILS
--------------------------------------------------------------------------------
- Suchfeld aus dem Wissen-Tab entfernt
- "Neue Kategorie"-Button zentral positioniert
- Globales Suchfeld erkennt aktiven Tab und leitet Suchanfragen entsprechend weiter
- Suchfeld wird automatisch geleert beim Wechsel zwischen Tabs
- Konsistentes Suchverhalten über alle Bereiche (Board, Vorschläge, Wissen)
BETROFFENE DATEIEN
--------------------------------------------------------------------------------
- frontend/js/knowledge.js: Öffentliche setSearchQuery() Methode hinzugefügt,
alte Suchfeld-Referenzen entfernt
- frontend/js/app.js: setupSearch() und switchView() erweitert für
Knowledge-Suche Integration
- frontend/css/knowledge.css: Suchfeld-Styles entfernt, zentrierten Header
hinzugefügt
- frontend/index.html: Suchfeld aus Knowledge-Tab entfernt
- frontend/sw.js: Cache-Version auf 148 erhöht
================================================================================
30.12.2025 - NEUES FEATURE: Wissensmanagement-Tab
================================================================================
FEATURE: WISSENSMANAGEMENT-TAB
--------------------------------------------------------------------------------
Neuer Tab "Wissen" in der Hauptnavigation für projektunabhängiges
Wissensmanagement mit 2-Ebenen-Hierarchie.
KATEGORIEN
--------------------------------------------------------------------------------
- Kategorien mit Namen, Beschreibung, Farbe und Icon (Emoji)
- Kategorien-Grid mit Eintrags-Anzahl
- Erstellen, Bearbeiten und Löschen von Kategorien
- Farbcodierung für visuelle Unterscheidung
EINTRÄGE
--------------------------------------------------------------------------------
- Einträge innerhalb von Kategorien
- Jeder Eintrag kann enthalten:
- Titel (Pflichtfeld)
- Link/URL (optional, klickbar)
- Notizen (optional, Markdown-fähig)
- Dateianhänge (mehrere pro Eintrag möglich)
- Erstellen, Bearbeiten und Löschen von Einträgen
SUCHFUNKTION
--------------------------------------------------------------------------------
- Dedizierte Suche nur für den Wissensmanagement-Bereich
- Durchsucht Titel, Notizen und URLs
- Debounce für Performance-Optimierung
- Ergebnisse zeigen Kategorie-Zugehörigkeit
BETROFFENE DATEIEN
--------------------------------------------------------------------------------
Backend:
- backend/database.js: 3 neue Tabellen (knowledge_categories, knowledge_entries,
knowledge_attachments) mit Indizes
- backend/routes/knowledge.js: NEU - CRUD-Operationen für Kategorien, Einträge,
Anhänge und Suche
- backend/server.js: Route-Registrierung hinzugefügt
Frontend:
- frontend/js/api.js: 15+ neue API-Methoden für Wissensmanagement
- frontend/js/knowledge.js: NEU - KnowledgeManager-Klasse
- frontend/css/knowledge.css: NEU - Styles für Kategorien, Einträge, Suche
- frontend/index.html: Tab, View und Modals hinzugefügt
- frontend/js/app.js: Import und Integration des KnowledgeManager
- frontend/sw.js: Cache-Version auf 144 erhöht, neue Dateien hinzugefügt
================================================================================
30.12.2025 - KRITISCHER BUGFIX: Login-Problem behoben
================================================================================
BUGFIX: SOFORTIGER LOGOUT NACH ERFOLGREICHEM LOGIN
--------------------------------------------------------------------------------
Problem: Nach erfolgreichem Login wurde der Benutzer sofort wieder ausgeloggt.
Ursache: Race-Condition zwischen Session-Refresh und Login:
1. Beim Seitenaufruf mit altem Token startete refreshSession() einen async Request
2. Benutzer logt sich erfolgreich ein (neuer Token wird gesetzt)
3. Der alte Refresh-Request kehrt mit 401 zurück
4. Der 401-Fehler löste logout() aus, obwohl neuer gültiger Token existierte
Lösung:
- Vor dem Logout wird geprüft ob der aktuelle Token noch der gleiche ist
- Wenn ein neuer Login stattfand (Token unterschiedlich), wird 401 ignoriert
- Prüfung in auth.js (refreshSession) und api.js (request) hinzugefügt
BETROFFENE DATEIEN
--------------------------------------------------------------------------------
- frontend/js/auth.js: refreshSession() prüft Token vor Logout
- frontend/js/api.js: request() prüft Token vor Logout bei 401
- frontend/sw.js: Cache-Version auf 143 erhöht
================================================================================ ================================================================================
30.12.2025 - Checklisten-Unteraufgaben: Drag & Drop und Bearbeiten 30.12.2025 - Checklisten-Unteraufgaben: Drag & Drop und Bearbeiten
================================================================================ ================================================================================

Datei anzeigen

@ -409,6 +409,54 @@ function createTables() {
) )
`); `);
// Wissensmanagement - Kategorien
db.exec(`
CREATE TABLE IF NOT EXISTS knowledge_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
color TEXT DEFAULT '#3B82F6',
icon TEXT,
position INTEGER NOT NULL DEFAULT 0,
created_by INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(id)
)
`);
// Wissensmanagement - Einträge
db.exec(`
CREATE TABLE IF NOT EXISTS knowledge_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER NOT NULL,
title TEXT NOT NULL,
url TEXT,
notes TEXT,
position INTEGER NOT NULL DEFAULT 0,
created_by INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES knowledge_categories(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id)
)
`);
// Wissensmanagement - Anhänge
db.exec(`
CREATE TABLE IF NOT EXISTS knowledge_attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entry_id INTEGER NOT NULL,
filename TEXT NOT NULL,
original_name TEXT NOT NULL,
mime_type TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
uploaded_by INTEGER,
uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (entry_id) REFERENCES knowledge_entries(id) ON DELETE CASCADE,
FOREIGN KEY (uploaded_by) REFERENCES users(id)
)
`);
// Indizes für Performance // Indizes für Performance
db.exec(` db.exec(`
CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id); CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id);
@ -426,6 +474,8 @@ function createTables() {
CREATE INDEX IF NOT EXISTS idx_notifications_user_read ON notifications(user_id, is_read); CREATE INDEX IF NOT EXISTS idx_notifications_user_read ON notifications(user_id, is_read);
CREATE INDEX IF NOT EXISTS idx_notifications_created ON notifications(created_at); CREATE INDEX IF NOT EXISTS idx_notifications_created ON notifications(created_at);
CREATE INDEX IF NOT EXISTS idx_applications_project ON applications(project_id); CREATE INDEX IF NOT EXISTS idx_applications_project ON applications(project_id);
CREATE INDEX IF NOT EXISTS idx_knowledge_entries_category ON knowledge_entries(category_id);
CREATE INDEX IF NOT EXISTS idx_knowledge_attachments_entry ON knowledge_attachments(entry_id);
`); `);
logger.info('Datenbank-Tabellen erstellt'); logger.info('Datenbank-Tabellen erstellt');

Datei anzeigen

@ -45,6 +45,10 @@ function verifyToken(token) {
try { try {
return jwt.verify(token, JWT_SECRET); return jwt.verify(token, JWT_SECRET);
} catch (error) { } catch (error) {
// Nur bei unerwarteten Fehlern loggen (nicht bei normalen Ablauf/Ungültig-Fällen)
if (error.name !== 'TokenExpiredError' && error.name !== 'JsonWebTokenError') {
logger.error(`[AUTH] Unerwarteter Token-Fehler: ${error.name} - ${error.message}`);
}
return null; return null;
} }
} }

944
backend/routes/knowledge.js Normale Datei
Datei anzeigen

@ -0,0 +1,944 @@
/**
* TASKMATE - Knowledge Management Routes
* ======================================
* CRUD für Wissensmanagement: Kategorien, Einträge, Anhänge
*/
const express = require('express');
const router = express.Router();
const path = require('path');
const fs = require('fs');
const multer = require('multer');
const { getDb } = require('../database');
const logger = require('../utils/logger');
const { validators, stripHtml } = require('../middleware/validation');
// Upload-Konfiguration für Knowledge-Anhänge
const UPLOAD_DIR = path.join(__dirname, '..', 'uploads', 'knowledge');
// Sicherstellen, dass das Upload-Verzeichnis existiert
if (!fs.existsSync(UPLOAD_DIR)) {
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
}
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, UPLOAD_DIR);
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const ext = path.extname(file.originalname);
cb(null, `knowledge-${uniqueSuffix}${ext}`);
}
});
const upload = multer({
storage,
limits: {
fileSize: 50 * 1024 * 1024 // 50MB max
}
});
// =====================
// KATEGORIEN
// =====================
/**
* GET /api/knowledge/categories
* Alle Kategorien abrufen
*/
router.get('/categories', (req, res) => {
try {
const db = getDb();
const categories = db.prepare(`
SELECT kc.*,
(SELECT COUNT(*) FROM knowledge_entries WHERE category_id = kc.id) as entry_count,
u.display_name as creator_name
FROM knowledge_categories kc
LEFT JOIN users u ON kc.created_by = u.id
ORDER BY kc.position, kc.created_at
`).all();
res.json(categories.map(c => ({
id: c.id,
name: c.name,
description: c.description,
color: c.color,
icon: c.icon,
position: c.position,
entryCount: c.entry_count,
createdBy: c.created_by,
creatorName: c.creator_name,
createdAt: c.created_at
})));
} catch (error) {
logger.error('Fehler beim Abrufen der Knowledge-Kategorien:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* POST /api/knowledge/categories
* Neue Kategorie erstellen
*/
router.post('/categories', (req, res) => {
try {
const { name, description, color, icon } = req.body;
// Validierung
const nameError = validators.required(name, 'Name') ||
validators.maxLength(name, 50, 'Name');
if (nameError) {
return res.status(400).json({ error: nameError });
}
if (color) {
const colorError = validators.hexColor(color, 'Farbe');
if (colorError) {
return res.status(400).json({ error: colorError });
}
}
const db = getDb();
// Duplikat-Prüfung
const existing = db.prepare(
'SELECT id FROM knowledge_categories WHERE LOWER(name) = LOWER(?)'
).get(name);
if (existing) {
return res.status(400).json({ error: 'Eine Kategorie mit diesem Namen existiert bereits' });
}
// Position ermitteln
const lastPosition = db.prepare(
'SELECT MAX(position) as max_pos FROM knowledge_categories'
).get();
const position = (lastPosition.max_pos ?? -1) + 1;
// Einfügen
const result = db.prepare(`
INSERT INTO knowledge_categories (name, description, color, icon, position, created_by)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
stripHtml(name),
description ? stripHtml(description) : null,
color || '#3B82F6',
icon || null,
position,
req.user.id
);
const category = db.prepare('SELECT * FROM knowledge_categories WHERE id = ?')
.get(result.lastInsertRowid);
logger.info(`Knowledge-Kategorie erstellt: ${name}`);
res.status(201).json({
id: category.id,
name: category.name,
description: category.description,
color: category.color,
icon: category.icon,
position: category.position,
entryCount: 0,
createdBy: category.created_by,
createdAt: category.created_at
});
} catch (error) {
logger.error('Fehler beim Erstellen der Knowledge-Kategorie:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* PUT /api/knowledge/categories/:id
* Kategorie aktualisieren
*/
router.put('/categories/:id', (req, res) => {
try {
const categoryId = req.params.id;
const { name, description, color, icon } = req.body;
const db = getDb();
const existing = db.prepare('SELECT * FROM knowledge_categories WHERE id = ?').get(categoryId);
if (!existing) {
return res.status(404).json({ error: 'Kategorie nicht gefunden' });
}
// Validierung
if (name) {
const nameError = validators.maxLength(name, 50, 'Name');
if (nameError) {
return res.status(400).json({ error: nameError });
}
// Duplikat-Prüfung (ausser eigene)
const duplicate = db.prepare(
'SELECT id FROM knowledge_categories WHERE LOWER(name) = LOWER(?) AND id != ?'
).get(name, categoryId);
if (duplicate) {
return res.status(400).json({ error: 'Eine Kategorie mit diesem Namen existiert bereits' });
}
}
if (color) {
const colorError = validators.hexColor(color, 'Farbe');
if (colorError) {
return res.status(400).json({ error: colorError });
}
}
db.prepare(`
UPDATE knowledge_categories SET
name = COALESCE(?, name),
description = COALESCE(?, description),
color = COALESCE(?, color),
icon = COALESCE(?, icon)
WHERE id = ?
`).run(
name ? stripHtml(name) : null,
description !== undefined ? (description ? stripHtml(description) : '') : null,
color || null,
icon !== undefined ? icon : null,
categoryId
);
const category = db.prepare('SELECT * FROM knowledge_categories WHERE id = ?').get(categoryId);
logger.info(`Knowledge-Kategorie aktualisiert: ${category.name}`);
res.json({
id: category.id,
name: category.name,
description: category.description,
color: category.color,
icon: category.icon,
position: category.position,
createdBy: category.created_by,
createdAt: category.created_at
});
} catch (error) {
logger.error('Fehler beim Aktualisieren der Knowledge-Kategorie:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* PUT /api/knowledge/categories/:id/position
* Kategorie-Position ändern
*/
router.put('/categories/:id/position', (req, res) => {
try {
const categoryId = req.params.id;
const { newPosition } = req.body;
const db = getDb();
const category = db.prepare('SELECT * FROM knowledge_categories WHERE id = ?').get(categoryId);
if (!category) {
return res.status(404).json({ error: 'Kategorie nicht gefunden' });
}
const oldPosition = category.position;
if (newPosition > oldPosition) {
db.prepare(`
UPDATE knowledge_categories SET position = position - 1
WHERE position > ? AND position <= ?
`).run(oldPosition, newPosition);
} else if (newPosition < oldPosition) {
db.prepare(`
UPDATE knowledge_categories SET position = position + 1
WHERE position >= ? AND position < ?
`).run(newPosition, oldPosition);
}
db.prepare('UPDATE knowledge_categories SET position = ? WHERE id = ?').run(newPosition, categoryId);
const categories = db.prepare(
'SELECT * FROM knowledge_categories ORDER BY position'
).all();
res.json({
categories: categories.map(c => ({
id: c.id,
name: c.name,
position: c.position
}))
});
} catch (error) {
logger.error('Fehler beim Verschieben der Knowledge-Kategorie:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* DELETE /api/knowledge/categories/:id
* Kategorie löschen (inkl. aller Einträge und Anhänge)
*/
router.delete('/categories/:id', (req, res) => {
try {
const categoryId = req.params.id;
const db = getDb();
const category = db.prepare('SELECT * FROM knowledge_categories WHERE id = ?').get(categoryId);
if (!category) {
return res.status(404).json({ error: 'Kategorie nicht gefunden' });
}
// Anhänge von allen Einträgen dieser Kategorie löschen
const entries = db.prepare('SELECT id FROM knowledge_entries WHERE category_id = ?').all(categoryId);
for (const entry of entries) {
const attachments = db.prepare('SELECT * FROM knowledge_attachments WHERE entry_id = ?').all(entry.id);
for (const attachment of attachments) {
const filePath = path.join(UPLOAD_DIR, attachment.filename);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
}
// Kategorie löschen (CASCADE löscht Einträge und Anhänge aus DB)
db.prepare('DELETE FROM knowledge_categories WHERE id = ?').run(categoryId);
// Positionen neu nummerieren
const remaining = db.prepare(
'SELECT id FROM knowledge_categories ORDER BY position'
).all();
remaining.forEach((c, idx) => {
db.prepare('UPDATE knowledge_categories SET position = ? WHERE id = ?').run(idx, c.id);
});
logger.info(`Knowledge-Kategorie gelöscht: ${category.name}`);
res.json({ message: 'Kategorie gelöscht' });
} catch (error) {
logger.error('Fehler beim Löschen der Knowledge-Kategorie:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
// =====================
// EINTRÄGE
// =====================
/**
* GET /api/knowledge/entries
* Einträge abrufen (optional gefiltert nach Kategorie)
*/
router.get('/entries', (req, res) => {
try {
const { categoryId } = req.query;
const db = getDb();
let query = `
SELECT ke.*,
kc.name as category_name,
kc.color as category_color,
u.display_name as creator_name,
(SELECT COUNT(*) FROM knowledge_attachments WHERE entry_id = ke.id) as attachment_count
FROM knowledge_entries ke
LEFT JOIN knowledge_categories kc ON ke.category_id = kc.id
LEFT JOIN users u ON ke.created_by = u.id
`;
const params = [];
if (categoryId) {
query += ' WHERE ke.category_id = ?';
params.push(categoryId);
}
query += ' ORDER BY ke.position, ke.created_at DESC';
const entries = db.prepare(query).all(...params);
res.json(entries.map(e => ({
id: e.id,
categoryId: e.category_id,
categoryName: e.category_name,
categoryColor: e.category_color,
title: e.title,
url: e.url,
notes: e.notes,
position: e.position,
attachmentCount: e.attachment_count,
createdBy: e.created_by,
creatorName: e.creator_name,
createdAt: e.created_at,
updatedAt: e.updated_at
})));
} catch (error) {
logger.error('Fehler beim Abrufen der Knowledge-Einträge:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* GET /api/knowledge/entries/:id
* Einzelnen Eintrag abrufen (mit Anhängen)
*/
router.get('/entries/:id', (req, res) => {
try {
const entryId = req.params.id;
const db = getDb();
const entry = db.prepare(`
SELECT ke.*,
kc.name as category_name,
kc.color as category_color,
u.display_name as creator_name
FROM knowledge_entries ke
LEFT JOIN knowledge_categories kc ON ke.category_id = kc.id
LEFT JOIN users u ON ke.created_by = u.id
WHERE ke.id = ?
`).get(entryId);
if (!entry) {
return res.status(404).json({ error: 'Eintrag nicht gefunden' });
}
const attachments = db.prepare(`
SELECT ka.*, u.display_name as uploader_name
FROM knowledge_attachments ka
LEFT JOIN users u ON ka.uploaded_by = u.id
WHERE ka.entry_id = ?
ORDER BY ka.uploaded_at DESC
`).all(entryId);
res.json({
id: entry.id,
categoryId: entry.category_id,
categoryName: entry.category_name,
categoryColor: entry.category_color,
title: entry.title,
url: entry.url,
notes: entry.notes,
position: entry.position,
createdBy: entry.created_by,
creatorName: entry.creator_name,
createdAt: entry.created_at,
updatedAt: entry.updated_at,
attachments: attachments.map(a => ({
id: a.id,
filename: a.filename,
originalName: a.original_name,
mimeType: a.mime_type,
sizeBytes: a.size_bytes,
uploadedBy: a.uploaded_by,
uploaderName: a.uploader_name,
uploadedAt: a.uploaded_at
}))
});
} catch (error) {
logger.error('Fehler beim Abrufen des Knowledge-Eintrags:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* POST /api/knowledge/entries
* Neuen Eintrag erstellen
*/
router.post('/entries', (req, res) => {
try {
const { categoryId, title, url, notes } = req.body;
// Validierung
const categoryError = validators.required(categoryId, 'Kategorie');
if (categoryError) {
return res.status(400).json({ error: categoryError });
}
const titleError = validators.required(title, 'Titel') ||
validators.maxLength(title, 200, 'Titel');
if (titleError) {
return res.status(400).json({ error: titleError });
}
if (url) {
const urlError = validators.url(url, 'URL');
if (urlError) {
return res.status(400).json({ error: urlError });
}
}
const db = getDb();
// Kategorie prüfen
const category = db.prepare('SELECT * FROM knowledge_categories WHERE id = ?').get(categoryId);
if (!category) {
return res.status(404).json({ error: 'Kategorie nicht gefunden' });
}
// Position ermitteln
const lastPosition = db.prepare(
'SELECT MAX(position) as max_pos FROM knowledge_entries WHERE category_id = ?'
).get(categoryId);
const position = (lastPosition.max_pos ?? -1) + 1;
// Einfügen
const result = db.prepare(`
INSERT INTO knowledge_entries (category_id, title, url, notes, position, created_by)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
categoryId,
stripHtml(title),
url || null,
notes || null,
position,
req.user.id
);
const entry = db.prepare('SELECT * FROM knowledge_entries WHERE id = ?')
.get(result.lastInsertRowid);
logger.info(`Knowledge-Eintrag erstellt: ${title}`);
res.status(201).json({
id: entry.id,
categoryId: entry.category_id,
title: entry.title,
url: entry.url,
notes: entry.notes,
position: entry.position,
attachmentCount: 0,
createdBy: entry.created_by,
createdAt: entry.created_at,
updatedAt: entry.updated_at
});
} catch (error) {
logger.error('Fehler beim Erstellen des Knowledge-Eintrags:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* PUT /api/knowledge/entries/:id
* Eintrag aktualisieren
*/
router.put('/entries/:id', (req, res) => {
try {
const entryId = req.params.id;
const { categoryId, title, url, notes } = req.body;
const db = getDb();
const existing = db.prepare('SELECT * FROM knowledge_entries WHERE id = ?').get(entryId);
if (!existing) {
return res.status(404).json({ error: 'Eintrag nicht gefunden' });
}
// Validierung
if (title) {
const titleError = validators.maxLength(title, 200, 'Titel');
if (titleError) {
return res.status(400).json({ error: titleError });
}
}
if (url) {
const urlError = validators.url(url, 'URL');
if (urlError) {
return res.status(400).json({ error: urlError });
}
}
if (categoryId) {
const category = db.prepare('SELECT * FROM knowledge_categories WHERE id = ?').get(categoryId);
if (!category) {
return res.status(404).json({ error: 'Kategorie nicht gefunden' });
}
}
db.prepare(`
UPDATE knowledge_entries SET
category_id = COALESCE(?, category_id),
title = COALESCE(?, title),
url = ?,
notes = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(
categoryId || null,
title ? stripHtml(title) : null,
url !== undefined ? url : existing.url,
notes !== undefined ? notes : existing.notes,
entryId
);
const entry = db.prepare(`
SELECT ke.*, kc.name as category_name, kc.color as category_color
FROM knowledge_entries ke
LEFT JOIN knowledge_categories kc ON ke.category_id = kc.id
WHERE ke.id = ?
`).get(entryId);
logger.info(`Knowledge-Eintrag aktualisiert: ${entry.title}`);
res.json({
id: entry.id,
categoryId: entry.category_id,
categoryName: entry.category_name,
categoryColor: entry.category_color,
title: entry.title,
url: entry.url,
notes: entry.notes,
position: entry.position,
createdBy: entry.created_by,
createdAt: entry.created_at,
updatedAt: entry.updated_at
});
} catch (error) {
logger.error('Fehler beim Aktualisieren des Knowledge-Eintrags:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* PUT /api/knowledge/entries/:id/position
* Eintrag-Position ändern (oder in andere Kategorie verschieben)
*/
router.put('/entries/:id/position', (req, res) => {
try {
const entryId = req.params.id;
const { newPosition, newCategoryId } = req.body;
const db = getDb();
const entry = db.prepare('SELECT * FROM knowledge_entries WHERE id = ?').get(entryId);
if (!entry) {
return res.status(404).json({ error: 'Eintrag nicht gefunden' });
}
const oldPosition = entry.position;
const oldCategoryId = entry.category_id;
const targetCategoryId = newCategoryId || oldCategoryId;
// Wenn Kategorie wechselt
if (targetCategoryId !== oldCategoryId) {
// Alte Kategorie: Positionen nach dem entfernten Eintrag reduzieren
db.prepare(`
UPDATE knowledge_entries SET position = position - 1
WHERE category_id = ? AND position > ?
`).run(oldCategoryId, oldPosition);
// Neue Kategorie: Platz für neuen Eintrag schaffen
db.prepare(`
UPDATE knowledge_entries SET position = position + 1
WHERE category_id = ? AND position >= ?
`).run(targetCategoryId, newPosition);
// Eintrag verschieben
db.prepare(`
UPDATE knowledge_entries SET category_id = ?, position = ?
WHERE id = ?
`).run(targetCategoryId, newPosition, entryId);
} else {
// Innerhalb der gleichen Kategorie
if (newPosition > oldPosition) {
db.prepare(`
UPDATE knowledge_entries SET position = position - 1
WHERE category_id = ? AND position > ? AND position <= ?
`).run(oldCategoryId, oldPosition, newPosition);
} else if (newPosition < oldPosition) {
db.prepare(`
UPDATE knowledge_entries SET position = position + 1
WHERE category_id = ? AND position >= ? AND position < ?
`).run(oldCategoryId, newPosition, oldPosition);
}
db.prepare('UPDATE knowledge_entries SET position = ? WHERE id = ?').run(newPosition, entryId);
}
res.json({ message: 'Position aktualisiert' });
} catch (error) {
logger.error('Fehler beim Verschieben des Knowledge-Eintrags:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* DELETE /api/knowledge/entries/:id
* Eintrag löschen
*/
router.delete('/entries/:id', (req, res) => {
try {
const entryId = req.params.id;
const db = getDb();
const entry = db.prepare('SELECT * FROM knowledge_entries WHERE id = ?').get(entryId);
if (!entry) {
return res.status(404).json({ error: 'Eintrag nicht gefunden' });
}
// Anhänge vom Dateisystem löschen
const attachments = db.prepare('SELECT * FROM knowledge_attachments WHERE entry_id = ?').all(entryId);
for (const attachment of attachments) {
const filePath = path.join(UPLOAD_DIR, attachment.filename);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
const categoryId = entry.category_id;
// Eintrag löschen
db.prepare('DELETE FROM knowledge_entries WHERE id = ?').run(entryId);
// Positionen neu nummerieren
const remaining = db.prepare(
'SELECT id FROM knowledge_entries WHERE category_id = ? ORDER BY position'
).all(categoryId);
remaining.forEach((e, idx) => {
db.prepare('UPDATE knowledge_entries SET position = ? WHERE id = ?').run(idx, e.id);
});
logger.info(`Knowledge-Eintrag gelöscht: ${entry.title}`);
res.json({ message: 'Eintrag gelöscht' });
} catch (error) {
logger.error('Fehler beim Löschen des Knowledge-Eintrags:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
// =====================
// ANHÄNGE
// =====================
/**
* GET /api/knowledge/attachments/:entryId
* Anhänge eines Eintrags abrufen
*/
router.get('/attachments/:entryId', (req, res) => {
try {
const entryId = req.params.entryId;
const db = getDb();
const entry = db.prepare('SELECT * FROM knowledge_entries WHERE id = ?').get(entryId);
if (!entry) {
return res.status(404).json({ error: 'Eintrag nicht gefunden' });
}
const attachments = db.prepare(`
SELECT ka.*, u.display_name as uploader_name
FROM knowledge_attachments ka
LEFT JOIN users u ON ka.uploaded_by = u.id
WHERE ka.entry_id = ?
ORDER BY ka.uploaded_at DESC
`).all(entryId);
res.json(attachments.map(a => ({
id: a.id,
entryId: a.entry_id,
filename: a.filename,
originalName: a.original_name,
mimeType: a.mime_type,
sizeBytes: a.size_bytes,
uploadedBy: a.uploaded_by,
uploaderName: a.uploader_name,
uploadedAt: a.uploaded_at
})));
} catch (error) {
logger.error('Fehler beim Abrufen der Knowledge-Anhänge:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* POST /api/knowledge/attachments/:entryId
* Anhang hochladen
*/
router.post('/attachments/:entryId', upload.single('file'), (req, res) => {
try {
const entryId = req.params.entryId;
const db = getDb();
const entry = db.prepare('SELECT * FROM knowledge_entries WHERE id = ?').get(entryId);
if (!entry) {
// Hochgeladene Datei löschen
if (req.file) {
fs.unlinkSync(req.file.path);
}
return res.status(404).json({ error: 'Eintrag nicht gefunden' });
}
if (!req.file) {
return res.status(400).json({ error: 'Keine Datei hochgeladen' });
}
const result = db.prepare(`
INSERT INTO knowledge_attachments (entry_id, filename, original_name, mime_type, size_bytes, uploaded_by)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
entryId,
req.file.filename,
req.file.originalname,
req.file.mimetype,
req.file.size,
req.user.id
);
// Eintrag updated_at aktualisieren
db.prepare('UPDATE knowledge_entries SET updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(entryId);
const attachment = db.prepare('SELECT * FROM knowledge_attachments WHERE id = ?')
.get(result.lastInsertRowid);
logger.info(`Knowledge-Anhang hochgeladen: ${req.file.originalname} für Eintrag ${entry.title}`);
res.status(201).json({
id: attachment.id,
entryId: attachment.entry_id,
filename: attachment.filename,
originalName: attachment.original_name,
mimeType: attachment.mime_type,
sizeBytes: attachment.size_bytes,
uploadedBy: attachment.uploaded_by,
uploadedAt: attachment.uploaded_at
});
} catch (error) {
logger.error('Fehler beim Hochladen des Knowledge-Anhangs:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* GET /api/knowledge/attachments/download/:id
* Anhang herunterladen
*/
router.get('/attachments/download/:id', (req, res) => {
try {
const attachmentId = req.params.id;
const db = getDb();
const attachment = db.prepare('SELECT * FROM knowledge_attachments WHERE id = ?').get(attachmentId);
if (!attachment) {
return res.status(404).json({ error: 'Anhang nicht gefunden' });
}
const filePath = path.join(UPLOAD_DIR, attachment.filename);
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'Datei nicht gefunden' });
}
res.download(filePath, attachment.original_name);
} catch (error) {
logger.error('Fehler beim Herunterladen des Knowledge-Anhangs:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* DELETE /api/knowledge/attachments/:id
* Anhang löschen
*/
router.delete('/attachments/:id', (req, res) => {
try {
const attachmentId = req.params.id;
const db = getDb();
const attachment = db.prepare('SELECT * FROM knowledge_attachments WHERE id = ?').get(attachmentId);
if (!attachment) {
return res.status(404).json({ error: 'Anhang nicht gefunden' });
}
// Datei vom Dateisystem löschen
const filePath = path.join(UPLOAD_DIR, attachment.filename);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
// Aus Datenbank löschen
db.prepare('DELETE FROM knowledge_attachments WHERE id = ?').run(attachmentId);
// Eintrag updated_at aktualisieren
db.prepare('UPDATE knowledge_entries SET updated_at = CURRENT_TIMESTAMP WHERE id = ?')
.run(attachment.entry_id);
logger.info(`Knowledge-Anhang gelöscht: ${attachment.original_name}`);
res.json({ message: 'Anhang gelöscht' });
} catch (error) {
logger.error('Fehler beim Löschen des Knowledge-Anhangs:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
// =====================
// SUCHE
// =====================
/**
* GET /api/knowledge/search
* Wissensmanagement durchsuchen
*/
router.get('/search', (req, res) => {
try {
const { q } = req.query;
if (!q || q.trim().length < 2) {
return res.json({ categories: [], entries: [] });
}
const searchTerm = `%${q.toLowerCase()}%`;
const db = getDb();
// Kategorien durchsuchen
const categories = db.prepare(`
SELECT kc.*,
(SELECT COUNT(*) FROM knowledge_entries WHERE category_id = kc.id) as entry_count
FROM knowledge_categories kc
WHERE LOWER(kc.name) LIKE ? OR LOWER(kc.description) LIKE ?
ORDER BY kc.position
`).all(searchTerm, searchTerm);
// Einträge durchsuchen
const entries = db.prepare(`
SELECT ke.*,
kc.name as category_name,
kc.color as category_color,
(SELECT COUNT(*) FROM knowledge_attachments WHERE entry_id = ke.id) as attachment_count
FROM knowledge_entries ke
LEFT JOIN knowledge_categories kc ON ke.category_id = kc.id
WHERE LOWER(ke.title) LIKE ? OR LOWER(ke.notes) LIKE ? OR LOWER(ke.url) LIKE ?
ORDER BY
CASE
WHEN LOWER(ke.title) LIKE ? THEN 1
ELSE 2
END,
ke.created_at DESC
LIMIT 50
`).all(searchTerm, searchTerm, searchTerm, searchTerm);
res.json({
categories: categories.map(c => ({
id: c.id,
name: c.name,
description: c.description,
color: c.color,
icon: c.icon,
entryCount: c.entry_count
})),
entries: entries.map(e => ({
id: e.id,
categoryId: e.category_id,
categoryName: e.category_name,
categoryColor: e.category_color,
title: e.title,
url: e.url,
notes: e.notes,
attachmentCount: e.attachment_count,
createdAt: e.created_at
}))
});
} catch (error) {
logger.error('Fehler bei der Knowledge-Suche:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
module.exports = router;

Datei anzeigen

@ -57,16 +57,16 @@ router.post('/', (req, res) => {
return res.status(404).json({ error: 'Aufgabe nicht gefunden' }); return res.status(404).json({ error: 'Aufgabe nicht gefunden' });
} }
// Höchste Position ermitteln // Alle bestehenden Subtasks um eine Position nach unten verschieben
const maxPos = db.prepare( db.prepare(`
'SELECT COALESCE(MAX(position), -1) as max FROM subtasks WHERE task_id = ?' UPDATE subtasks SET position = position + 1 WHERE task_id = ?
).get(taskId).max; `).run(taskId);
// Subtask erstellen // Neue Subtask an Position 0 erstellen (immer an erster Stelle)
const result = db.prepare(` const result = db.prepare(`
INSERT INTO subtasks (task_id, title, position) INSERT INTO subtasks (task_id, title, position)
VALUES (?, ?, ?) VALUES (?, ?, 0)
`).run(taskId, title, maxPos + 1); `).run(taskId, title);
// Task updated_at aktualisieren // Task updated_at aktualisieren
db.prepare('UPDATE tasks SET updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(taskId); db.prepare('UPDATE tasks SET updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(taskId);

Datei anzeigen

@ -41,6 +41,7 @@ const notificationService = require('./services/notificationService');
const gitRoutes = require('./routes/git'); const gitRoutes = require('./routes/git');
const applicationsRoutes = require('./routes/applications'); const applicationsRoutes = require('./routes/applications');
const giteaRoutes = require('./routes/gitea'); const giteaRoutes = require('./routes/gitea');
const knowledgeRoutes = require('./routes/knowledge');
// Express App erstellen // Express App erstellen
const app = express(); const app = express();
@ -144,6 +145,9 @@ app.use('/api/applications', authenticateToken, csrfProtection, applicationsRout
// Gitea-Routes (Gitea API Integration) // Gitea-Routes (Gitea API Integration)
app.use('/api/gitea', authenticateToken, csrfProtection, giteaRoutes); app.use('/api/gitea', authenticateToken, csrfProtection, giteaRoutes);
// Knowledge-Routes (Wissensmanagement)
app.use('/api/knowledge', authenticateToken, csrfProtection, knowledgeRoutes);
// ============================================================================= // =============================================================================
// SOCKET.IO // SOCKET.IO
// ============================================================================= // =============================================================================

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

853
frontend/css/knowledge.css Normale Datei
Datei anzeigen

@ -0,0 +1,853 @@
/**
* TASKMATE - Knowledge Management Styles
* ======================================
* Sidebar + Main Layout mit Drag & Drop
*/
/* ============================================
VIEW CONTAINER
============================================ */
.view-knowledge {
padding: var(--spacing-md);
height: calc(100vh - 120px);
display: flex;
flex-direction: column;
}
/* ============================================
MAIN LAYOUT (Sidebar + Main)
============================================ */
.knowledge-layout {
display: grid;
grid-template-columns: 280px 1fr;
gap: var(--spacing-lg);
flex: 1;
min-height: 0;
overflow: hidden;
}
/* ============================================
SIDEBAR (Kategorien)
============================================ */
.knowledge-sidebar {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
display: flex;
flex-direction: column;
overflow: hidden;
}
.knowledge-sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-md);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.knowledge-sidebar-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.knowledge-category-list {
flex: 1;
overflow-y: auto;
padding: var(--spacing-xs) 0;
}
.knowledge-sidebar-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--spacing-xl);
color: var(--text-muted);
text-align: center;
}
.knowledge-sidebar-empty svg {
opacity: 0.4;
margin-bottom: var(--spacing-sm);
}
.knowledge-sidebar-empty p {
margin: 0;
font-size: 0.9rem;
}
/* ============================================
KATEGORIE-ITEMS (Sidebar)
============================================ */
.knowledge-category-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
cursor: pointer;
border-left: 3px solid transparent;
transition: all 0.15s ease;
user-select: none;
}
.knowledge-category-item:hover {
background: var(--bg-hover);
}
.knowledge-category-item.active {
border-left-color: var(--category-color, var(--primary));
background: var(--bg-hover);
}
.knowledge-category-item.dragging {
opacity: 0.5;
background: var(--primary-light);
}
.knowledge-category-item.drag-over {
border-top: 2px solid var(--primary);
}
.knowledge-category-icon {
font-size: 1.2rem;
flex-shrink: 0;
width: 28px;
text-align: center;
}
.knowledge-category-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.knowledge-category-name {
font-size: 0.9rem;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.knowledge-category-count {
font-size: 0.75rem;
color: var(--text-muted);
}
.knowledge-category-actions {
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 0.15s;
}
.knowledge-category-item:hover .knowledge-category-actions {
opacity: 1;
}
.knowledge-category-actions .btn-icon {
width: 24px;
height: 24px;
padding: 0;
background: transparent;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--text-muted);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.knowledge-category-actions .btn-icon:hover {
background: var(--bg-secondary);
color: var(--text-primary);
}
.knowledge-category-actions .btn-icon.btn-danger-hover:hover {
background: var(--danger-bg);
color: var(--danger);
}
.knowledge-drag-handle {
cursor: grab;
opacity: 0;
color: var(--text-muted);
font-size: 1rem;
padding: 0 var(--spacing-xs);
transition: opacity 0.15s;
}
.knowledge-category-item:hover .knowledge-drag-handle {
opacity: 0.5;
}
.knowledge-drag-handle:hover {
opacity: 1 !important;
}
.knowledge-drag-handle:active {
cursor: grabbing;
}
/* ============================================
HAUPTBEREICH (Einträge)
============================================ */
.knowledge-main {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.knowledge-main-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--border-color);
margin-bottom: var(--spacing-md);
flex-shrink: 0;
}
.knowledge-main-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.knowledge-main-title .category-icon {
font-size: 1.3rem;
}
/* ============================================
EINTRÄGE-LISTE
============================================ */
.knowledge-entry-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
/* ============================================
EINTRAG-ITEMS (Kompakt + Expand)
============================================ */
.knowledge-entry-item {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
transition: all 0.15s ease;
user-select: none;
}
.knowledge-entry-item:hover {
border-color: var(--primary);
box-shadow: var(--shadow-sm);
}
.knowledge-entry-item.dragging {
opacity: 0.5;
box-shadow: var(--shadow-lg);
}
.knowledge-entry-item.drag-over {
border-top: 2px solid var(--primary);
margin-top: -2px;
}
/* Header (immer sichtbar) */
.knowledge-entry-header {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
cursor: pointer;
}
.knowledge-entry-expand {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
flex-shrink: 0;
color: var(--text-muted);
transition: transform 0.2s ease;
}
.knowledge-entry-item.expanded .knowledge-entry-expand {
transform: rotate(90deg);
}
.knowledge-entry-title {
flex: 1;
font-size: 0.95rem;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.knowledge-entry-indicators {
display: flex;
align-items: center;
gap: var(--spacing-xs);
color: var(--text-muted);
font-size: 0.8rem;
}
.knowledge-entry-indicator {
display: flex;
align-items: center;
gap: 2px;
}
.knowledge-entry-actions {
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 0.15s;
}
.knowledge-entry-item:hover .knowledge-entry-actions {
opacity: 1;
}
.knowledge-entry-actions .btn-icon {
width: 28px;
height: 28px;
padding: 0;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.knowledge-entry-actions .btn-icon:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.knowledge-entry-actions .btn-icon.btn-danger-hover:hover {
background: var(--danger-bg);
color: var(--danger);
border-color: var(--danger);
}
.knowledge-entry-drag-handle {
cursor: grab;
opacity: 0;
color: var(--text-muted);
padding: 0 var(--spacing-xs);
transition: opacity 0.15s;
}
.knowledge-entry-item:hover .knowledge-entry-drag-handle {
opacity: 0.5;
}
.knowledge-entry-drag-handle:hover {
opacity: 1 !important;
}
.knowledge-entry-drag-handle:active {
cursor: grabbing;
}
/* Details (aufklappbar) */
.knowledge-entry-details {
display: none;
padding: var(--spacing-md);
padding-top: 0;
border-top: 1px solid var(--border-light);
margin-top: var(--spacing-xs);
animation: fadeIn 0.15s ease;
}
.knowledge-entry-item.expanded .knowledge-entry-details {
display: block;
}
.knowledge-entry-url {
display: flex;
align-items: center;
gap: var(--spacing-xs);
font-size: 0.9rem;
color: var(--primary);
text-decoration: none;
margin-bottom: var(--spacing-sm);
word-break: break-all;
}
.knowledge-entry-url:hover {
text-decoration: underline;
}
.knowledge-entry-url svg {
flex-shrink: 0;
color: var(--text-muted);
}
.knowledge-entry-notes {
font-size: 0.9rem;
color: var(--text-secondary);
line-height: 1.5;
margin-bottom: var(--spacing-sm);
white-space: pre-wrap;
}
.knowledge-entry-attachments-info {
display: flex;
align-items: center;
gap: var(--spacing-xs);
font-size: 0.85rem;
color: var(--text-muted);
}
/* ============================================
EMPTY STATES
============================================ */
.knowledge-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--spacing-xxl) var(--spacing-lg);
text-align: center;
color: var(--text-muted);
flex: 1;
}
.knowledge-empty svg {
margin-bottom: var(--spacing-md);
opacity: 0.4;
}
.knowledge-empty h3 {
font-size: 1.1rem;
font-weight: 600;
color: var(--text-secondary);
margin: 0 0 var(--spacing-xs) 0;
}
.knowledge-empty p {
font-size: 0.95rem;
margin: 0;
}
/* ============================================
SUCHERGEBNISSE
============================================ */
.knowledge-search-results {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.knowledge-search-header {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--border-color);
margin-bottom: var(--spacing-md);
}
.knowledge-search-info {
font-size: 0.95rem;
color: var(--text-secondary);
}
.knowledge-search-info span {
font-weight: 500;
color: var(--text-primary);
}
/* Kategorie-Badge in Suchergebnissen */
.knowledge-entry-category-badge {
font-size: 0.7rem;
padding: 2px 8px;
border-radius: var(--radius-full);
background: var(--category-color, var(--primary));
color: white;
font-weight: 500;
}
/* ============================================
MOBILE KATEGORIEN (Chips)
============================================ */
.knowledge-mobile-categories {
display: none;
gap: var(--spacing-sm);
padding: var(--spacing-sm) 0;
overflow-x: auto;
flex-shrink: 0;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.knowledge-mobile-categories::-webkit-scrollbar {
display: none;
}
.knowledge-mobile-chip {
display: flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-md);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-full);
cursor: pointer;
white-space: nowrap;
font-size: 0.85rem;
color: var(--text-secondary);
transition: all 0.15s;
flex-shrink: 0;
}
.knowledge-mobile-chip:hover {
background: var(--bg-hover);
border-color: var(--primary);
}
.knowledge-mobile-chip.active {
background: var(--primary);
border-color: var(--primary);
color: white;
}
.knowledge-mobile-chip .chip-icon {
font-size: 1rem;
}
.knowledge-mobile-chip .chip-count {
font-size: 0.75rem;
opacity: 0.8;
}
.knowledge-mobile-add {
background: var(--primary);
border-color: var(--primary);
color: white;
}
/* ============================================
ATTACHMENTS (Modal)
============================================ */
.knowledge-attachments-container {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
margin-bottom: var(--spacing-sm);
}
.knowledge-attachment-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
}
.knowledge-attachment-item svg {
flex-shrink: 0;
color: var(--text-muted);
}
.knowledge-attachment-info {
flex: 1;
min-width: 0;
}
.knowledge-attachment-name {
font-size: 0.9rem;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.knowledge-attachment-size {
font-size: 0.8rem;
color: var(--text-muted);
}
.knowledge-attachment-actions {
display: flex;
gap: var(--spacing-xs);
}
.knowledge-attachment-actions .btn-icon {
width: 24px;
height: 24px;
padding: 0;
background: transparent;
border: none;
cursor: pointer;
color: var(--text-muted);
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
transition: all 0.15s;
}
.knowledge-attachment-actions .btn-icon:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.knowledge-attachment-actions .btn-icon.btn-danger-hover:hover {
background: var(--danger-bg);
color: var(--danger);
}
/* ============================================
DRAG & DROP INDICATOR
============================================ */
.knowledge-drop-indicator {
height: 2px;
background: var(--primary);
border-radius: 1px;
margin: var(--spacing-xs) 0;
}
/* ============================================
ANIMATIONS
============================================ */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* ============================================
RESPONSIVE
============================================ */
@media (max-width: 900px) {
.knowledge-layout {
grid-template-columns: 220px 1fr;
}
}
@media (max-width: 768px) {
.view-knowledge {
padding: var(--spacing-sm);
}
.knowledge-layout {
grid-template-columns: 1fr;
}
.knowledge-sidebar {
display: none;
}
.knowledge-mobile-categories {
display: flex;
}
.knowledge-main-header {
flex-wrap: wrap;
gap: var(--spacing-sm);
}
.knowledge-main-title {
font-size: 1.1rem;
}
/* Aktionen immer sichtbar auf Mobile */
.knowledge-entry-actions,
.knowledge-category-actions {
opacity: 1;
}
.knowledge-entry-drag-handle,
.knowledge-drag-handle {
opacity: 0.5;
}
}
@media (max-width: 480px) {
.knowledge-entry-header {
padding: var(--spacing-xs) var(--spacing-sm);
}
.knowledge-entry-details {
padding: var(--spacing-sm);
}
.knowledge-main-header {
padding-bottom: var(--spacing-sm);
margin-bottom: var(--spacing-sm);
}
}
/* ==========================================
ICON PICKER STYLES
========================================== */
.icon-picker-container {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.icon-picker-preview {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
background: var(--bg-secondary);
border: 2px solid var(--border-color);
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s ease;
}
.icon-picker-preview:hover {
border-color: var(--primary);
background: var(--bg-hover);
}
.icon-preview-emoji {
font-size: 1.75rem;
line-height: 1;
}
.icon-preview-label {
font-size: 0.8rem;
color: var(--text-secondary);
}
.icon-picker-section {
margin-top: var(--spacing-md);
padding-top: var(--spacing-md);
border-top: 1px solid var(--border-color);
}
.icon-picker-tabs {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
margin-bottom: var(--spacing-md);
}
.icon-tab {
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.icon-tab:hover {
background: var(--bg-hover);
border-color: var(--primary);
}
.icon-tab.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.icon-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(40px, 1fr));
gap: var(--spacing-xs);
max-height: 200px;
overflow-y: auto;
padding: var(--spacing-xs);
background: var(--bg-tertiary);
border-radius: var(--radius-md);
}
.icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: var(--bg-primary);
border: 1px solid transparent;
border-radius: var(--radius-sm);
font-size: 1.25rem;
cursor: pointer;
transition: all 0.15s ease;
}
.icon-btn:hover {
background: var(--bg-hover);
border-color: var(--primary);
transform: scale(1.1);
}
.icon-btn.selected {
background: var(--primary-light);
border-color: var(--primary);
box-shadow: 0 0 0 2px var(--primary-light);
}
/* Scrollbar für Icon Grid */
.icon-grid::-webkit-scrollbar {
width: 6px;
}
.icon-grid::-webkit-scrollbar-track {
background: var(--bg-tertiary);
border-radius: 3px;
}
.icon-grid::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
.icon-grid::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}

Datei anzeigen

@ -25,6 +25,7 @@
<link rel="stylesheet" href="css/proposals.css"> <link rel="stylesheet" href="css/proposals.css">
<link rel="stylesheet" href="css/notifications.css"> <link rel="stylesheet" href="css/notifications.css">
<link rel="stylesheet" href="css/gitea.css"> <link rel="stylesheet" href="css/gitea.css">
<link rel="stylesheet" href="css/knowledge.css">
<link rel="stylesheet" href="css/responsive.css"> <link rel="stylesheet" href="css/responsive.css">
<!-- Favicon --> <!-- Favicon -->
@ -235,6 +236,7 @@
<button class="view-tab" data-view="calendar">Kalender</button> <button class="view-tab" data-view="calendar">Kalender</button>
<button class="view-tab" data-view="proposals">Genehmigung</button> <button class="view-tab" data-view="proposals">Genehmigung</button>
<button class="view-tab" data-view="gitea">Gitea</button> <button class="view-tab" data-view="gitea">Gitea</button>
<button class="view-tab" data-view="knowledge">Wissen</button>
</nav> </nav>
</div> </div>
@ -886,6 +888,100 @@
</div> </div>
<!-- Knowledge View (Wissensmanagement) -->
<div id="view-knowledge" class="view view-knowledge hidden">
<!-- Mobile: Kategorien als horizontale Chips -->
<div id="knowledge-mobile-categories" class="knowledge-mobile-categories">
<!-- Wird dynamisch befüllt -->
</div>
<!-- Haupt-Layout: Sidebar + Main -->
<div class="knowledge-layout">
<!-- Sidebar (links) - Kategorien -->
<aside class="knowledge-sidebar">
<div class="knowledge-sidebar-header">
<h3 class="knowledge-sidebar-title">Kategorien</h3>
<button id="btn-new-category" class="btn btn-primary btn-sm">
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round"/></svg>
Neu
</button>
</div>
<div id="knowledge-categories" class="knowledge-category-list">
<!-- Kategorien werden dynamisch geladen -->
</div>
<div id="knowledge-categories-empty" class="knowledge-sidebar-empty hidden">
<svg viewBox="0 0 24 24" width="48" height="48">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" stroke="currentColor" stroke-width="2" fill="none"/>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" stroke="currentColor" stroke-width="2" fill="none"/>
</svg>
<p>Keine Kategorien</p>
</div>
</aside>
<!-- Hauptbereich (rechts) - Einträge -->
<main class="knowledge-main">
<div class="knowledge-main-header">
<h2 id="knowledge-category-title" class="knowledge-main-title">Kategorie wählen</h2>
<button id="btn-new-entry" class="btn btn-primary" disabled>
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round"/></svg>
Neuer Eintrag
</button>
</div>
<!-- Einträge-Liste -->
<div id="knowledge-entries" class="knowledge-entry-list">
<!-- Einträge werden dynamisch geladen -->
</div>
<!-- Leerer Zustand: Keine Kategorie ausgewählt -->
<div id="knowledge-no-selection" class="knowledge-empty">
<svg viewBox="0 0 24 24" width="64" height="64">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" stroke="currentColor" stroke-width="2" fill="none"/>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" stroke="currentColor" stroke-width="2" fill="none"/>
</svg>
<h3>Kategorie auswählen</h3>
<p>Wählen Sie links eine Kategorie aus, um Einträge anzuzeigen.</p>
</div>
<!-- Leerer Zustand: Keine Einträge -->
<div id="knowledge-entries-empty" class="knowledge-empty hidden">
<svg viewBox="0 0 24 24" width="64" height="64">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" stroke="currentColor" stroke-width="2" fill="none"/>
<path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" stroke="currentColor" stroke-width="2" fill="none"/>
</svg>
<h3>Keine Einträge</h3>
<p>Erstellen Sie den ersten Eintrag in dieser Kategorie.</p>
</div>
<!-- Suchergebnisse -->
<div id="knowledge-search-results" class="knowledge-search-results hidden">
<div class="knowledge-search-header">
<button id="btn-clear-search" class="btn btn-text">
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M6 18L18 6M6 6l12 12" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round"/></svg>
Suche beenden
</button>
<span class="knowledge-search-info">Ergebnisse für "<span id="knowledge-search-query"></span>"</span>
</div>
<div id="knowledge-search-list" class="knowledge-entry-list">
<!-- Suchergebnisse werden hier angezeigt -->
</div>
<div id="knowledge-search-empty" class="knowledge-empty hidden">
<svg viewBox="0 0 24 24" width="64" height="64">
<circle cx="11" cy="11" r="8" stroke="currentColor" stroke-width="2" fill="none"/>
<path d="M21 21l-4.35-4.35" stroke="currentColor" stroke-width="2" fill="none"/>
</svg>
<h3>Keine Ergebnisse</h3>
<p>Keine Einträge gefunden.</p>
</div>
</div>
</main>
</div>
</div>
</main> </main>
</div> </div>
@ -1479,6 +1575,339 @@
</div> </div>
</div> </div>
<!-- Knowledge Category Modal -->
<div id="knowledge-category-modal" class="modal modal-small hidden">
<div class="modal-header">
<h2 id="knowledge-category-modal-title">Neue Kategorie</h2>
<button class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<form id="knowledge-category-form">
<input type="hidden" id="knowledge-category-id">
<div class="form-group">
<label for="knowledge-category-name">Name *</label>
<input type="text" id="knowledge-category-name" required maxlength="100" placeholder="z.B. Technik">
</div>
<div class="form-group">
<label for="knowledge-category-description">Beschreibung</label>
<textarea id="knowledge-category-description" rows="2" placeholder="Optionale Beschreibung..."></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="knowledge-category-color">Farbe</label>
<input type="color" id="knowledge-category-color" value="#3B82F6">
</div>
<div class="form-group">
<label>Icon</label>
<div class="icon-picker-container">
<div class="icon-picker-preview" id="icon-picker-preview">
<span class="icon-preview-emoji" id="icon-preview-emoji">📁</span>
<span class="icon-preview-label">Klicken zum Ändern</span>
</div>
<input type="hidden" id="knowledge-category-icon" value="📁">
</div>
</div>
</div>
<!-- Icon Picker Grid -->
<div class="icon-picker-section" id="icon-picker-section">
<div class="icon-picker-tabs">
<button type="button" class="icon-tab active" data-tab="tech">💻 Technik</button>
<button type="button" class="icon-tab" data-tab="network">🌐 Netzwerk</button>
<button type="button" class="icon-tab" data-tab="office">📋 Büro</button>
<button type="button" class="icon-tab" data-tab="finance">💰 Finanzen</button>
<button type="button" class="icon-tab" data-tab="home">🏠 Haus</button>
<button type="button" class="icon-tab" data-tab="misc">⭐ Sonstige</button>
</div>
<!-- Technik Icons -->
<div class="icon-grid" data-tab-content="tech">
<button type="button" class="icon-btn" data-icon="💻">💻</button>
<button type="button" class="icon-btn" data-icon="🖥️">🖥️</button>
<button type="button" class="icon-btn" data-icon="🖨️">🖨️</button>
<button type="button" class="icon-btn" data-icon="⌨️">⌨️</button>
<button type="button" class="icon-btn" data-icon="🖱️">🖱️</button>
<button type="button" class="icon-btn" data-icon="💾">💾</button>
<button type="button" class="icon-btn" data-icon="💿">💿</button>
<button type="button" class="icon-btn" data-icon="📀">📀</button>
<button type="button" class="icon-btn" data-icon="🔌">🔌</button>
<button type="button" class="icon-btn" data-icon="🔋">🔋</button>
<button type="button" class="icon-btn" data-icon="📱">📱</button>
<button type="button" class="icon-btn" data-icon="📲">📲</button>
<button type="button" class="icon-btn" data-icon="☎️">☎️</button>
<button type="button" class="icon-btn" data-icon="📞">📞</button>
<button type="button" class="icon-btn" data-icon="📟">📟</button>
<button type="button" class="icon-btn" data-icon="📠">📠</button>
<button type="button" class="icon-btn" data-icon="📺">📺</button>
<button type="button" class="icon-btn" data-icon="📷">📷</button>
<button type="button" class="icon-btn" data-icon="📹">📹</button>
<button type="button" class="icon-btn" data-icon="🎥">🎥</button>
<button type="button" class="icon-btn" data-icon="🔦">🔦</button>
<button type="button" class="icon-btn" data-icon="💡">💡</button>
<button type="button" class="icon-btn" data-icon="🔧">🔧</button>
<button type="button" class="icon-btn" data-icon="🔨">🔨</button>
<button type="button" class="icon-btn" data-icon="⚙️">⚙️</button>
<button type="button" class="icon-btn" data-icon="🛠️">🛠️</button>
<button type="button" class="icon-btn" data-icon="🔩">🔩</button>
<button type="button" class="icon-btn" data-icon="⛏️">⛏️</button>
<button type="button" class="icon-btn" data-icon="🧰">🧰</button>
<button type="button" class="icon-btn" data-icon="🎮">🎮</button>
<button type="button" class="icon-btn" data-icon="🕹️">🕹️</button>
<button type="button" class="icon-btn" data-icon="🎧">🎧</button>
<button type="button" class="icon-btn" data-icon="🎤">🎤</button>
<button type="button" class="icon-btn" data-icon="🔊">🔊</button>
<button type="button" class="icon-btn" data-icon="📡">📡</button>
<button type="button" class="icon-btn" data-icon="🔬">🔬</button>
</div>
<!-- Netzwerk Icons -->
<div class="icon-grid hidden" data-tab-content="network">
<button type="button" class="icon-btn" data-icon="🌐">🌐</button>
<button type="button" class="icon-btn" data-icon="🌍">🌍</button>
<button type="button" class="icon-btn" data-icon="🌎">🌎</button>
<button type="button" class="icon-btn" data-icon="🌏">🌏</button>
<button type="button" class="icon-btn" data-icon="📡">📡</button>
<button type="button" class="icon-btn" data-icon="📶">📶</button>
<button type="button" class="icon-btn" data-icon="🛜">🛜</button>
<button type="button" class="icon-btn" data-icon="📤">📤</button>
<button type="button" class="icon-btn" data-icon="📥">📥</button>
<button type="button" class="icon-btn" data-icon="📧">📧</button>
<button type="button" class="icon-btn" data-icon="✉️">✉️</button>
<button type="button" class="icon-btn" data-icon="📨">📨</button>
<button type="button" class="icon-btn" data-icon="📩">📩</button>
<button type="button" class="icon-btn" data-icon="🔗">🔗</button>
<button type="button" class="icon-btn" data-icon="⛓️">⛓️</button>
<button type="button" class="icon-btn" data-icon="🔐">🔐</button>
<button type="button" class="icon-btn" data-icon="🔒">🔒</button>
<button type="button" class="icon-btn" data-icon="🔓">🔓</button>
<button type="button" class="icon-btn" data-icon="🔑">🔑</button>
<button type="button" class="icon-btn" data-icon="🗝️">🗝️</button>
<button type="button" class="icon-btn" data-icon="🛡️">🛡️</button>
<button type="button" class="icon-btn" data-icon="⚡"></button>
<button type="button" class="icon-btn" data-icon="☁️">☁️</button>
<button type="button" class="icon-btn" data-icon="🖧">🖧</button>
<button type="button" class="icon-btn" data-icon="🔄">🔄</button>
<button type="button" class="icon-btn" data-icon="♻️">♻️</button>
<button type="button" class="icon-btn" data-icon="🔃">🔃</button>
<button type="button" class="icon-btn" data-icon="🌀">🌀</button>
<button type="button" class="icon-btn" data-icon="💠">💠</button>
<button type="button" class="icon-btn" data-icon="🔷">🔷</button>
</div>
<!-- Büro Icons -->
<div class="icon-grid hidden" data-tab-content="office">
<button type="button" class="icon-btn" data-icon="📁">📁</button>
<button type="button" class="icon-btn" data-icon="📂">📂</button>
<button type="button" class="icon-btn" data-icon="🗂️">🗂️</button>
<button type="button" class="icon-btn" data-icon="📋">📋</button>
<button type="button" class="icon-btn" data-icon="📄">📄</button>
<button type="button" class="icon-btn" data-icon="📃">📃</button>
<button type="button" class="icon-btn" data-icon="📑">📑</button>
<button type="button" class="icon-btn" data-icon="📊">📊</button>
<button type="button" class="icon-btn" data-icon="📈">📈</button>
<button type="button" class="icon-btn" data-icon="📉">📉</button>
<button type="button" class="icon-btn" data-icon="📝">📝</button>
<button type="button" class="icon-btn" data-icon="✏️">✏️</button>
<button type="button" class="icon-btn" data-icon="✒️">✒️</button>
<button type="button" class="icon-btn" data-icon="🖊️">🖊️</button>
<button type="button" class="icon-btn" data-icon="🖋️">🖋️</button>
<button type="button" class="icon-btn" data-icon="📌">📌</button>
<button type="button" class="icon-btn" data-icon="📍">📍</button>
<button type="button" class="icon-btn" data-icon="📎">📎</button>
<button type="button" class="icon-btn" data-icon="🖇️">🖇️</button>
<button type="button" class="icon-btn" data-icon="📏">📏</button>
<button type="button" class="icon-btn" data-icon="📐">📐</button>
<button type="button" class="icon-btn" data-icon="✂️">✂️</button>
<button type="button" class="icon-btn" data-icon="🗃️">🗃️</button>
<button type="button" class="icon-btn" data-icon="🗄️">🗄️</button>
<button type="button" class="icon-btn" data-icon="🗑️">🗑️</button>
<button type="button" class="icon-btn" data-icon="📆">📆</button>
<button type="button" class="icon-btn" data-icon="📅">📅</button>
<button type="button" class="icon-btn" data-icon="🗓️">🗓️</button>
<button type="button" class="icon-btn" data-icon="⏰"></button>
<button type="button" class="icon-btn" data-icon="🕐">🕐</button>
<button type="button" class="icon-btn" data-icon="📰">📰</button>
<button type="button" class="icon-btn" data-icon="🗞️">🗞️</button>
<button type="button" class="icon-btn" data-icon="📓">📓</button>
<button type="button" class="icon-btn" data-icon="📔">📔</button>
<button type="button" class="icon-btn" data-icon="📒">📒</button>
<button type="button" class="icon-btn" data-icon="📕">📕</button>
<button type="button" class="icon-btn" data-icon="📗">📗</button>
<button type="button" class="icon-btn" data-icon="📘">📘</button>
<button type="button" class="icon-btn" data-icon="📙">📙</button>
<button type="button" class="icon-btn" data-icon="📚">📚</button>
</div>
<!-- Finanzen Icons -->
<div class="icon-grid hidden" data-tab-content="finance">
<button type="button" class="icon-btn" data-icon="💰">💰</button>
<button type="button" class="icon-btn" data-icon="💵">💵</button>
<button type="button" class="icon-btn" data-icon="💴">💴</button>
<button type="button" class="icon-btn" data-icon="💶">💶</button>
<button type="button" class="icon-btn" data-icon="💷">💷</button>
<button type="button" class="icon-btn" data-icon="💸">💸</button>
<button type="button" class="icon-btn" data-icon="💳">💳</button>
<button type="button" class="icon-btn" data-icon="🏦">🏦</button>
<button type="button" class="icon-btn" data-icon="🏧">🏧</button>
<button type="button" class="icon-btn" data-icon="💹">💹</button>
<button type="button" class="icon-btn" data-icon="🧾">🧾</button>
<button type="button" class="icon-btn" data-icon="💲">💲</button>
<button type="button" class="icon-btn" data-icon="💱">💱</button>
<button type="button" class="icon-btn" data-icon="🪙">🪙</button>
<button type="button" class="icon-btn" data-icon="💎">💎</button>
<button type="button" class="icon-btn" data-icon="⚖️">⚖️</button>
<button type="button" class="icon-btn" data-icon="🏛️">🏛️</button>
<button type="button" class="icon-btn" data-icon="🏢">🏢</button>
<button type="button" class="icon-btn" data-icon="🏪">🏪</button>
<button type="button" class="icon-btn" data-icon="🛒">🛒</button>
<button type="button" class="icon-btn" data-icon="🛍️">🛍️</button>
<button type="button" class="icon-btn" data-icon="🎁">🎁</button>
<button type="button" class="icon-btn" data-icon="🎫">🎫</button>
<button type="button" class="icon-btn" data-icon="🎟️">🎟️</button>
</div>
<!-- Haus/Wohnen Icons -->
<div class="icon-grid hidden" data-tab-content="home">
<button type="button" class="icon-btn" data-icon="🏠">🏠</button>
<button type="button" class="icon-btn" data-icon="🏡">🏡</button>
<button type="button" class="icon-btn" data-icon="🏘️">🏘️</button>
<button type="button" class="icon-btn" data-icon="🏚️">🏚️</button>
<button type="button" class="icon-btn" data-icon="🚪">🚪</button>
<button type="button" class="icon-btn" data-icon="🪟">🪟</button>
<button type="button" class="icon-btn" data-icon="🛏️">🛏️</button>
<button type="button" class="icon-btn" data-icon="🛋️">🛋️</button>
<button type="button" class="icon-btn" data-icon="🪑">🪑</button>
<button type="button" class="icon-btn" data-icon="🚿">🚿</button>
<button type="button" class="icon-btn" data-icon="🛁">🛁</button>
<button type="button" class="icon-btn" data-icon="🚽">🚽</button>
<button type="button" class="icon-btn" data-icon="🧹">🧹</button>
<button type="button" class="icon-btn" data-icon="🧺">🧺</button>
<button type="button" class="icon-btn" data-icon="🧴">🧴</button>
<button type="button" class="icon-btn" data-icon="🪥">🪥</button>
<button type="button" class="icon-btn" data-icon="🍳">🍳</button>
<button type="button" class="icon-btn" data-icon="🍴">🍴</button>
<button type="button" class="icon-btn" data-icon="🥄">🥄</button>
<button type="button" class="icon-btn" data-icon="🔪">🔪</button>
<button type="button" class="icon-btn" data-icon="🏺">🏺</button>
<button type="button" class="icon-btn" data-icon="🌡️">🌡️</button>
<button type="button" class="icon-btn" data-icon="🌿">🌿</button>
<button type="button" class="icon-btn" data-icon="🪴">🪴</button>
<button type="button" class="icon-btn" data-icon="🚗">🚗</button>
<button type="button" class="icon-btn" data-icon="🚙">🚙</button>
<button type="button" class="icon-btn" data-icon="🚕">🚕</button>
<button type="button" class="icon-btn" data-icon="🛵">🛵</button>
<button type="button" class="icon-btn" data-icon="🚲">🚲</button>
<button type="button" class="icon-btn" data-icon="⛽"></button>
</div>
<!-- Sonstige Icons -->
<div class="icon-grid hidden" data-tab-content="misc">
<button type="button" class="icon-btn" data-icon="⭐"></button>
<button type="button" class="icon-btn" data-icon="🌟">🌟</button>
<button type="button" class="icon-btn" data-icon="✨"></button>
<button type="button" class="icon-btn" data-icon="💫">💫</button>
<button type="button" class="icon-btn" data-icon="❤️">❤️</button>
<button type="button" class="icon-btn" data-icon="💙">💙</button>
<button type="button" class="icon-btn" data-icon="💚">💚</button>
<button type="button" class="icon-btn" data-icon="💛">💛</button>
<button type="button" class="icon-btn" data-icon="💜">💜</button>
<button type="button" class="icon-btn" data-icon="🧡">🧡</button>
<button type="button" class="icon-btn" data-icon="✅"></button>
<button type="button" class="icon-btn" data-icon="❌"></button>
<button type="button" class="icon-btn" data-icon="⚠️">⚠️</button>
<button type="button" class="icon-btn" data-icon="❗"></button>
<button type="button" class="icon-btn" data-icon="❓"></button>
<button type="button" class="icon-btn" data-icon=""></button>
<button type="button" class="icon-btn" data-icon="🔔">🔔</button>
<button type="button" class="icon-btn" data-icon="🔕">🔕</button>
<button type="button" class="icon-btn" data-icon="🎯">🎯</button>
<button type="button" class="icon-btn" data-icon="🎲">🎲</button>
<button type="button" class="icon-btn" data-icon="🧩">🧩</button>
<button type="button" class="icon-btn" data-icon="🏆">🏆</button>
<button type="button" class="icon-btn" data-icon="🥇">🥇</button>
<button type="button" class="icon-btn" data-icon="🥈">🥈</button>
<button type="button" class="icon-btn" data-icon="🥉">🥉</button>
<button type="button" class="icon-btn" data-icon="🎵">🎵</button>
<button type="button" class="icon-btn" data-icon="🎶">🎶</button>
<button type="button" class="icon-btn" data-icon="🎬">🎬</button>
<button type="button" class="icon-btn" data-icon="🎨">🎨</button>
<button type="button" class="icon-btn" data-icon="🎭">🎭</button>
<button type="button" class="icon-btn" data-icon="👤">👤</button>
<button type="button" class="icon-btn" data-icon="👥">👥</button>
<button type="button" class="icon-btn" data-icon="👨‍💼">👨‍💼</button>
<button type="button" class="icon-btn" data-icon="👩‍💼">👩‍💼</button>
<button type="button" class="icon-btn" data-icon="👨‍💻">👨‍💻</button>
<button type="button" class="icon-btn" data-icon="👩‍💻">👩‍💻</button>
<button type="button" class="icon-btn" data-icon="🧑‍🔧">🧑‍🔧</button>
<button type="button" class="icon-btn" data-icon="👷">👷</button>
<button type="button" class="icon-btn" data-icon="🔴">🔴</button>
<button type="button" class="icon-btn" data-icon="🟠">🟠</button>
<button type="button" class="icon-btn" data-icon="🟡">🟡</button>
<button type="button" class="icon-btn" data-icon="🟢">🟢</button>
<button type="button" class="icon-btn" data-icon="🔵">🔵</button>
<button type="button" class="icon-btn" data-icon="🟣">🟣</button>
<button type="button" class="icon-btn" data-icon="⚪"></button>
<button type="button" class="icon-btn" data-icon="⚫"></button>
<button type="button" class="icon-btn" data-icon="🟤">🟤</button>
<button type="button" class="icon-btn" data-icon="🔶">🔶</button>
<button type="button" class="icon-btn" data-icon="🔸">🔸</button>
<button type="button" class="icon-btn" data-icon="🔹">🔹</button>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" form="knowledge-category-form" class="btn btn-primary" id="btn-save-category">Speichern</button>
</div>
</div>
<!-- Knowledge Entry Modal -->
<div id="knowledge-entry-modal" class="modal modal-medium hidden">
<div class="modal-header">
<h2 id="knowledge-entry-modal-title">Neuer Eintrag</h2>
<button class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<form id="knowledge-entry-form">
<input type="hidden" id="knowledge-entry-id">
<input type="hidden" id="knowledge-entry-category-id">
<div class="form-group">
<label for="knowledge-entry-title">Titel *</label>
<input type="text" id="knowledge-entry-title" required maxlength="200" placeholder="z.B. Fritzbox Router">
</div>
<div class="form-group">
<label for="knowledge-entry-url">Link (URL)</label>
<input type="url" id="knowledge-entry-url" placeholder="https://...">
</div>
<div class="form-group">
<label for="knowledge-entry-notes">Notizen</label>
<textarea id="knowledge-entry-notes" rows="5" placeholder="Notizen, Anleitungen, Tipps... (Markdown unterstützt)"></textarea>
</div>
<!-- Anhänge (nur im Bearbeitungsmodus) -->
<div class="form-group" id="knowledge-attachments-section" style="display: none;">
<label>Anhänge</label>
<div id="knowledge-attachments-container" class="knowledge-attachments-container"></div>
<div class="file-upload-area" id="knowledge-file-upload-area">
<svg class="icon" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12" stroke="currentColor" stroke-width="2" fill="none"/></svg>
<span>Dateien hierher ziehen oder <label for="knowledge-file-input" class="file-input-label">auswählen</label></span>
<input type="file" id="knowledge-file-input" multiple hidden>
<span class="file-hint">Max. 15 MB pro Datei</span>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<div class="modal-footer-left">
<button type="button" id="btn-delete-entry" class="btn btn-text text-danger hidden">Löschen</button>
</div>
<div class="modal-footer-right">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" form="knowledge-entry-form" class="btn btn-primary" id="btn-save-entry">Speichern</button>
</div>
</div>
</div>
<!-- Toast Container --> <!-- Toast Container -->
<div id="toast-container" class="toast-container"></div> <div id="toast-container" class="toast-container"></div>

Datei anzeigen

@ -14,19 +14,23 @@ class ApiClient {
// Token Management // Token Management
setToken(token) { setToken(token) {
console.log('[API] setToken:', token ? token.substring(0, 20) + '...' : 'NULL');
this.token = token; this.token = token;
if (token) { if (token) {
localStorage.setItem('auth_token', token); localStorage.setItem('auth_token', token);
} else { } else {
this.token = null;
localStorage.removeItem('auth_token'); localStorage.removeItem('auth_token');
localStorage.removeItem('current_user');
} }
} }
getToken() { getToken() {
if (!this.token) { // IMMER aus localStorage lesen um Synchronisationsprobleme zu vermeiden
this.token = localStorage.getItem('auth_token'); // (z.B. wenn Token nach Login gesetzt wird während andere Requests laufen)
} const token = localStorage.getItem('auth_token');
return this.token; this.token = token; // Cache aktualisieren
return token;
} }
setCsrfToken(token) { setCsrfToken(token) {
@ -39,10 +43,10 @@ class ApiClient {
} }
getCsrfToken() { getCsrfToken() {
if (!this.csrfToken) { // IMMER aus sessionStorage lesen um Synchronisationsprobleme zu vermeiden
this.csrfToken = sessionStorage.getItem('csrf_token'); const token = sessionStorage.getItem('csrf_token');
} this.csrfToken = token; // Cache aktualisieren
return this.csrfToken; return token;
} }
// Base Request Method // Base Request Method
@ -56,6 +60,7 @@ class ApiClient {
// Add auth token // Add auth token
const token = this.getToken(); const token = this.getToken();
console.log('[API] Request:', endpoint, 'Token:', token ? token.substring(0, 20) + '...' : 'NULL');
if (token) { if (token) {
headers['Authorization'] = `Bearer ${token}`; headers['Authorization'] = `Bearer ${token}`;
} }
@ -98,8 +103,22 @@ class ApiClient {
// Handle 401 Unauthorized // Handle 401 Unauthorized
if (response.status === 401) { if (response.status === 401) {
// Token der für diesen Request verwendet wurde
const requestToken = token;
const currentToken = localStorage.getItem('auth_token');
console.log('[API] 401 received for:', endpoint);
console.log('[API] Request token:', requestToken ? requestToken.substring(0, 20) + '...' : 'NULL');
console.log('[API] Current token:', currentToken ? currentToken.substring(0, 20) + '...' : 'NULL');
// Nur ausloggen wenn der Token der gleiche ist (kein neuer Login in der Zwischenzeit)
if (!currentToken || currentToken === requestToken) {
console.log('[API] Token invalid, triggering logout');
this.setToken(null); this.setToken(null);
window.dispatchEvent(new CustomEvent('auth:logout')); window.dispatchEvent(new CustomEvent('auth:logout'));
} else {
console.log('[API] 401 ignored - new login occurred while request was in flight');
}
throw new ApiError('Sitzung abgelaufen', 401); throw new ApiError('Sitzung abgelaufen', 401);
} }
@ -274,7 +293,9 @@ class ApiClient {
// ===================== // =====================
async login(username, password) { async login(username, password) {
console.log('[API] login() called');
const response = await this.post('/auth/login', { username, password }); const response = await this.post('/auth/login', { username, password });
console.log('[API] login() response:', response ? 'OK' : 'NULL', 'token:', response?.token ? 'EXISTS' : 'MISSING');
this.setToken(response.token); this.setToken(response.token);
// Store CSRF token from login response // Store CSRF token from login response
if (response.csrfToken) { if (response.csrfToken) {
@ -977,6 +998,79 @@ class ApiClient {
xhr.send(formData); xhr.send(formData);
}); });
} }
// =====================
// KNOWLEDGE ENDPOINTS (Wissensmanagement)
// =====================
// Kategorien
async getKnowledgeCategories() {
return this.get('/knowledge/categories');
}
async createKnowledgeCategory(data) {
return this.post('/knowledge/categories', data);
}
async updateKnowledgeCategory(id, data) {
return this.put(`/knowledge/categories/${id}`, data);
}
async deleteKnowledgeCategory(id) {
return this.delete(`/knowledge/categories/${id}`);
}
async updateKnowledgeCategoryPosition(id, newPosition) {
return this.put(`/knowledge/categories/${id}/position`, { newPosition });
}
// Einträge
async getKnowledgeEntries(categoryId = null) {
const params = categoryId ? `?categoryId=${categoryId}` : '';
return this.get(`/knowledge/entries${params}`);
}
async getKnowledgeEntry(id) {
return this.get(`/knowledge/entries/${id}`);
}
async createKnowledgeEntry(data) {
return this.post('/knowledge/entries', data);
}
async updateKnowledgeEntry(id, data) {
return this.put(`/knowledge/entries/${id}`, data);
}
async deleteKnowledgeEntry(id) {
return this.delete(`/knowledge/entries/${id}`);
}
async updateKnowledgeEntryPosition(id, newPosition, newCategoryId = null) {
return this.put(`/knowledge/entries/${id}/position`, { newPosition, newCategoryId });
}
// Anhänge
async getKnowledgeAttachments(entryId) {
return this.get(`/knowledge/attachments/${entryId}`);
}
async uploadKnowledgeAttachment(entryId, file, onProgress) {
return this.uploadFile(`/knowledge/attachments/${entryId}`, file, onProgress);
}
async deleteKnowledgeAttachment(id) {
return this.delete(`/knowledge/attachments/${id}`);
}
getKnowledgeAttachmentDownloadUrl(id) {
return `${this.baseUrl}/knowledge/attachments/download/${id}`;
}
// Suche
async searchKnowledge(query) {
return this.get(`/knowledge/search?q=${encodeURIComponent(query)}`);
}
} }
// Custom API Error Class // Custom API Error Class

Datei anzeigen

@ -20,6 +20,7 @@ import adminManager from './admin.js';
import proposalsManager from './proposals.js'; import proposalsManager from './proposals.js';
import notificationManager from './notifications.js'; import notificationManager from './notifications.js';
import giteaManager from './gitea.js'; import giteaManager from './gitea.js';
import knowledgeManager from './knowledge.js';
import { $, $$, debounce, getFromStorage, setToStorage } from './utils.js'; import { $, $$, debounce, getFromStorage, setToStorage } from './utils.js';
class App { class App {
@ -79,6 +80,9 @@ class App {
// Initialize gitea manager // Initialize gitea manager
await giteaManager.init(); await giteaManager.init();
// Initialize knowledge manager
await knowledgeManager.init();
// Update UI // Update UI
this.updateUserMenu(); this.updateUserMenu();
} }
@ -596,6 +600,18 @@ class App {
v.classList.toggle('hidden', !isActive); v.classList.toggle('hidden', !isActive);
}); });
// Clear search field when switching views
const searchInput = $('#search-input');
if (searchInput && searchInput.value) {
searchInput.value = '';
store.setFilter('search', '');
store.setState({ searchResultIds: [] }, 'CLEAR_SEARCH_RESULTS');
proposalsManager.setSearchQuery('');
knowledgeManager.setSearchQuery('');
$('#search-clear')?.classList.add('hidden');
$('.search-container')?.classList.remove('has-search');
}
// Load proposals when switching to proposals view - reset to active (non-archived) // Load proposals when switching to proposals view - reset to active (non-archived)
if (view === 'proposals') { if (view === 'proposals') {
proposalsManager.resetToActiveView(); proposalsManager.resetToActiveView();
@ -607,6 +623,13 @@ class App {
} else { } else {
giteaManager.hide(); giteaManager.hide();
} }
// Show/hide knowledge manager
if (view === 'knowledge') {
knowledgeManager.show();
} else {
knowledgeManager.hide();
}
} }
// ===================== // =====================
@ -823,8 +846,9 @@ class App {
updateSearchUI(''); updateSearchUI('');
searchInput.focus(); searchInput.focus();
// Clear proposals search as well // Clear view-specific search
proposalsManager.setSearchQuery(''); proposalsManager.setSearchQuery('');
knowledgeManager.setSearchQuery('');
// Cancel any pending server search // Cancel any pending server search
if (searchAbortController) { if (searchAbortController) {
@ -897,6 +921,9 @@ class App {
if (currentView === 'proposals') { if (currentView === 'proposals') {
// Search proposals only // Search proposals only
proposalsManager.setSearchQuery(value); proposalsManager.setSearchQuery(value);
} else if (currentView === 'knowledge') {
// Search knowledge base
knowledgeManager.setSearchQuery(value);
} else { } else {
// Immediate client-side filtering for tasks // Immediate client-side filtering for tasks
store.setFilter('search', value); store.setFilter('search', value);

Datei anzeigen

@ -19,11 +19,14 @@ class AuthManager {
// Initialize authentication state // Initialize authentication state
async init() { async init() {
const token = api.getToken(); const token = api.getToken();
console.log('[Auth] init() - Token exists:', !!token);
if (token) { if (token) {
try { try {
// Verify token by making a request // Verify token by making a request
console.log('[Auth] Verifying token...');
const users = await api.getUsers(); const users = await api.getUsers();
console.log('[Auth] Token valid, users loaded');
this.isAuthenticated = true; this.isAuthenticated = true;
// Get current user from stored data // Get current user from stored data
@ -35,11 +38,13 @@ class AuthManager {
return true; return true;
} catch (error) { } catch (error) {
// Token invalid // Token invalid
console.log('[Auth] Token invalid, logging out');
this.logout(); this.logout();
return false; return false;
} }
} }
console.log('[Auth] No token found');
return false; return false;
} }
@ -471,8 +476,18 @@ class SessionTimerHandler {
} }
} }
} else if (response.status === 401) { } else if (response.status === 401) {
// Token ungültig - ausloggen // Token ungültig - aber nur ausloggen wenn kein neuer Login stattfand
// (Race-Condition: Alter Refresh-Request kann 401 zurückgeben nachdem
// ein neuer Login erfolgreich war)
const currentToken = localStorage.getItem('auth_token');
if (currentToken === token) {
// Gleicher Token, wirklich ungültig, ausloggen
console.log('[Auth] Refresh returned 401, logging out');
this.auth.logout(); this.auth.logout();
} else {
// Token hat sich geändert (neuer Login oder bereits ausgeloggt)
console.log('[Auth] Refresh 401 ignored - token changed (new login occurred)');
}
} }
} catch (error) { } catch (error) {
console.error('Session refresh error:', error); console.error('Session refresh error:', error);

1189
frontend/js/knowledge.js Normale Datei

Datei-Diff unterdrückt, da er zu groß ist Diff laden

Datei anzeigen

@ -21,7 +21,9 @@ class SyncManager {
// Initialize Socket.io connection // Initialize Socket.io connection
async connect() { async connect() {
if (this.socket?.connected) { // Verhindere doppelte Verbindungen (auch während des Verbindungsaufbaus)
if (this.socket) {
console.log('[Sync] Socket already exists, skipping connect');
return; return;
} }
@ -102,8 +104,15 @@ class SyncManager {
console.error('[Sync] Socket error:', error); console.error('[Sync] Socket error:', error);
if (error.type === 'auth') { if (error.type === 'auth') {
// Auth failed, logout // Nur ausloggen wenn wir wirklich nicht eingeloggt sind
// (verhindert Logout durch alte Socket-Verbindungen nach neuem Login)
const currentToken = localStorage.getItem('auth_token');
if (!currentToken) {
console.log('[Sync] Auth error and no token, triggering logout');
window.dispatchEvent(new CustomEvent('auth:logout')); window.dispatchEvent(new CustomEvent('auth:logout'));
} else {
console.log('[Sync] Auth error ignored - new login occurred');
}
} }
}); });
@ -546,9 +555,8 @@ class SyncManager {
const syncManager = new SyncManager(); const syncManager = new SyncManager();
// Listen for auth events // Listen for auth events
window.addEventListener('auth:login', () => { // Hinweis: syncManager.connect() wird NICHT hier aufgerufen,
syncManager.connect(); // sondern in app.js initializeApp() um doppelte Verbindungen zu vermeiden
});
window.addEventListener('auth:logout', () => { window.addEventListener('auth:logout', () => {
syncManager.disconnect(); syncManager.disconnect();

Datei anzeigen

@ -1321,7 +1321,8 @@ class TaskModalManager {
try { try {
const projectId = store.get('currentProjectId'); const projectId = store.get('currentProjectId');
const subtask = await api.createSubtask(projectId, this.taskId, { title }); const subtask = await api.createSubtask(projectId, this.taskId, { title });
this.subtasks.push(subtask); // Neue Subtask an erster Stelle einfügen
this.subtasks.unshift(subtask);
this.renderSubtasks(); this.renderSubtasks();
input.value = ''; input.value = '';
@ -1331,8 +1332,8 @@ class TaskModalManager {
this.showError('Fehler beim Hinzufügen'); this.showError('Fehler beim Hinzufügen');
} }
} else { } else {
// For new tasks, store locally // For new tasks, store locally - an erster Stelle
this.subtasks.push({ this.subtasks.unshift({
id: generateTempId(), id: generateTempId(),
title, title,
completed: false completed: false
@ -1346,6 +1347,7 @@ class TaskModalManager {
const subtask = this.subtasks.find(s => s.id === subtaskId); const subtask = this.subtasks.find(s => s.id === subtaskId);
if (!subtask) return; if (!subtask) return;
const wasCompleted = subtask.completed;
subtask.completed = !subtask.completed; subtask.completed = !subtask.completed;
if (this.mode === 'edit' && this.taskId) { if (this.mode === 'edit' && this.taskId) {
@ -1355,10 +1357,26 @@ class TaskModalManager {
completed: subtask.completed completed: subtask.completed
}); });
// Wenn abgehakt: ans Ende der Liste verschieben
if (subtask.completed && !wasCompleted) {
const currentIndex = this.subtasks.findIndex(s => s.id === subtaskId);
const lastPosition = this.subtasks.length - 1;
if (currentIndex < lastPosition) {
// Aus aktueller Position entfernen
const [moved] = this.subtasks.splice(currentIndex, 1);
// Ans Ende anfügen
this.subtasks.push(moved);
// API-Call für neue Position
await api.reorderSubtasks(projectId, this.taskId, subtaskId, lastPosition);
}
}
// Update subtask progress in store for immediate board update // Update subtask progress in store for immediate board update
this.updateSubtaskProgressInStore(); this.updateSubtaskProgressInStore();
} catch (error) { } catch (error) {
subtask.completed = !subtask.completed; subtask.completed = wasCompleted;
this.showError('Fehler beim Aktualisieren'); this.showError('Fehler beim Aktualisieren');
} }
} }

Datei anzeigen

@ -4,7 +4,7 @@
* Offline support and caching * Offline support and caching
*/ */
const CACHE_VERSION = '138'; const CACHE_VERSION = '152';
const CACHE_NAME = 'taskmate-v' + CACHE_VERSION; const CACHE_NAME = 'taskmate-v' + CACHE_VERSION;
const STATIC_CACHE_NAME = 'taskmate-static-v' + CACHE_VERSION; const STATIC_CACHE_NAME = 'taskmate-static-v' + CACHE_VERSION;
const DYNAMIC_CACHE_NAME = 'taskmate-dynamic-v' + CACHE_VERSION; const DYNAMIC_CACHE_NAME = 'taskmate-dynamic-v' + CACHE_VERSION;
@ -38,11 +38,13 @@ const STATIC_ASSETS = [
'/js/proposals.js', '/js/proposals.js',
'/js/notifications.js', '/js/notifications.js',
'/js/gitea.js', '/js/gitea.js',
'/js/knowledge.js',
'/css/list.css', '/css/list.css',
'/css/admin.css', '/css/admin.css',
'/css/proposals.css', '/css/proposals.css',
'/css/notifications.css', '/css/notifications.css',
'/css/gitea.css' '/css/gitea.css',
'/css/knowledge.css'
]; ];
// API routes to cache // API routes to cache

Datei-Diff unterdrückt, da er zu groß ist Diff laden