diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 02e2571..8324595 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,6 +1,28 @@ TASKMATE - CHANGELOG ==================== +================================================================================ +19.03.2026 - v391 - Sicherheitshärtung und toter Code bereinigt + +SICHERHEIT: +- CSRF-Bypass bei /api/admin und /api/proposals behoben (authenticateToken vor csrfProtection) +- CSRF-Schutz auf /api/files, /api/stats, /api/export hinzugefuegt +- CORS eingeschraenkt auf taskmate.aegis-sight.de statt wildcard +- JWT_SECRET nicht mehr aus auth.js exportiert +- Toter Code entfernt: generateCsrfToken(), SESSION_TIMEOUT, generateToken() Legacy-Wrapper +- generateToken() durch direkten generateAccessToken() Aufruf ersetzt + +TOTER CODE ENTFERNT: +- 7 ungenutzte Frontend-JS-Dateien geloescht (tour, dashboard, contacts-*) +- 2 ungenutzte CSS-Dateien geloescht (contacts-extended, dashboard) +- knowledge.js.backup geloescht +- backend/migrations/ Verzeichnis geloescht +- Doppelter bcrypt require in database.js entfernt +- Referenzen in index.html und sw.js bereinigt + +OPTIMIERUNG: +- Request-Logging filtert statische Assets raus (nur /api/ Requests) + ================================================================================ 19.03.2026 - v390 - Filter-Buttons in die View-Tabs-Zeile verschoben ================================================================================ diff --git a/backend/database.js b/backend/database.js index d16f7cf..82ac33b 100644 --- a/backend/database.js +++ b/backend/database.js @@ -763,8 +763,7 @@ async function createDefaultUsers() { const adminExists = db.prepare('SELECT id, password_hash FROM users WHERE email = ? AND role = ?').get('admin@taskmate.local', 'admin'); if (adminExists) { const correctAdminPassword = process.env.ADMIN_PASSWORD || 'admin123'; - const bcrypt = require('bcryptjs'); - + // Prüfen ob das Passwort bereits korrekt ist const isCorrect = await bcrypt.compare(correctAdminPassword, adminExists.password_hash); if (!isCorrect) { diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index 33f31cf..12acd90 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -15,8 +15,6 @@ if (!JWT_SECRET || JWT_SECRET.length < 32) { } const ACCESS_TOKEN_EXPIRY = 60; // Minuten (kürzer für mehr Sicherheit) const REFRESH_TOKEN_EXPIRY = 7 * 24 * 60; // 7 Tage in Minuten -const SESSION_TIMEOUT = parseInt(process.env.SESSION_TIMEOUT) || 30; // Minuten - /** * JWT Access-Token generieren (kurze Lebensdauer) */ @@ -63,13 +61,6 @@ function generateRefreshToken(userId, ipAddress, userAgent) { return token; } -/** - * Legacy generateToken für Rückwärtskompatibilität - */ -function generateToken(user) { - return generateAccessToken(user); -} - /** * JWT-Token verifizieren */ @@ -120,7 +111,7 @@ function authenticateToken(req, res, next) { const refreshThreshold = 5 * 60 * 1000; // 5 Minuten vor Ablauf if (tokenExp - now < refreshThreshold) { - const newToken = generateToken({ + const newToken = generateAccessToken({ id: user.id, username: user.username, display_name: user.displayName, @@ -203,14 +194,6 @@ function authenticateSocket(socket, next) { next(); } -/** - * CSRF-Token generieren (für Forms) - */ -function generateCsrfToken() { - const { randomBytes } = require('crypto'); - return randomBytes(32).toString('hex'); -} - /** * Refresh-Token validieren und neuen Access-Token generieren */ @@ -272,7 +255,6 @@ function cleanupExpiredTokens() { setInterval(cleanupExpiredTokens, 6 * 60 * 60 * 1000); module.exports = { - generateToken, generateAccessToken, generateRefreshToken, refreshAccessToken, @@ -280,10 +262,7 @@ module.exports = { verifyToken, authenticateToken, authenticateSocket, - generateCsrfToken, requireAdmin, requireRegularUser, - checkPermission, - JWT_SECRET, - SESSION_TIMEOUT + checkPermission }; diff --git a/backend/routes/auth.js b/backend/routes/auth.js index f77ba36..862f455 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -8,7 +8,7 @@ const express = require('express'); const router = express.Router(); const bcrypt = require('bcryptjs'); const { getDb } = require('../database'); -const { generateToken, generateRefreshToken, refreshAccessToken, revokeAllRefreshTokens, authenticateToken } = require('../middleware/auth'); +const { generateAccessToken, generateRefreshToken, refreshAccessToken, revokeAllRefreshTokens, authenticateToken } = require('../middleware/auth'); const { getTokenForUser } = require('../middleware/csrf'); const { validatePassword } = require('../middleware/validation'); const logger = require('../utils/logger'); @@ -112,7 +112,7 @@ router.post('/login', async (req, res) => { logAttempt(user.id, true); // JWT Access-Token generieren (kurze Lebensdauer) - const accessToken = generateToken(user); + const accessToken = generateAccessToken(user); // Refresh-Token generieren (lange Lebensdauer) const refreshToken = generateRefreshToken(user.id, ip, userAgent); @@ -267,7 +267,7 @@ function legacyRefresh(req, res) { return res.status(404).json({ error: 'Benutzer nicht gefunden' }); } - const newToken = generateToken(dbUser); + const newToken = generateAccessToken(dbUser); const csrfToken = getTokenForUser(dbUser.id); res.json({ token: newToken, csrfToken }); diff --git a/backend/routes/contacts-extended.js b/backend/routes/contacts-extended.js new file mode 100644 index 0000000..943276e --- /dev/null +++ b/backend/routes/contacts-extended.js @@ -0,0 +1,497 @@ +/** + * TASKMATE - Erweiterte Kontakte API + * =================================== + * REST API für Kontakte mit Institutionen + */ + +const router = require('express').Router(); +const { getDb } = require('../database'); +const logger = require('../utils/logger'); + +// =================================== +// INSTITUTIONEN +// =================================== + +// Alle Institutionen abrufen +router.get('/institutions', (req, res) => { + try { + const db = getDb(); + const { project_id, search, type } = req.query; + let query = 'SELECT * FROM institutions WHERE 1=1'; + const params = []; + + if (project_id) { + query += ' AND project_id = ?'; + params.push(project_id); + } + + if (search) { + query += ' AND (name LIKE ? OR description LIKE ? OR tags LIKE ?)'; + const searchPattern = `%${search}%`; + params.push(searchPattern, searchPattern, searchPattern); + } + + if (type) { + query += ' AND type = ?'; + params.push(type); + } + + query += ' ORDER BY name ASC'; + + const institutions = db.prepare(query).all(...params); + res.json({ success: true, data: institutions }); + } catch (error) { + logger.error('Fehler beim Abrufen der Institutionen:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +// Institution erstellen +router.post('/institutions', (req, res) => { + // Basis-Validierung + if (!req.body.name || !req.body.project_id) { + return res.status(400).json({ success: false, error: 'Name und Projekt-ID sind erforderlich' }); + } + + try { + const db = getDb(); + const stmt = db.prepare(` + INSERT INTO institutions ( + name, type, industry, website, logo_url, description, + main_address, main_postal_code, + main_city, main_state, main_country, trade_register, + notes, tags, created_by, project_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + const result = stmt.run( + req.body.name, + req.body.type || null, + req.body.industry || null, + req.body.website || null, + req.body.logo_url || null, + req.body.description || null, + req.body.main_address || null, + req.body.main_postal_code || null, + req.body.main_city || null, + req.body.main_state || null, + req.body.main_country || null, + req.body.trade_register || null, + req.body.notes || null, + req.body.tags || null, + req.userId, + req.body.project_id + ); + + const institution = db.prepare('SELECT * FROM institutions WHERE id = ?').get(result.lastInsertRowid); + + // Socket.io Event + req.app.get('io').to(`project-${req.body.project_id}`).emit('institution:created', institution); + + res.status(201).json({ success: true, data: institution }); + } catch (error) { + logger.error('Fehler beim Erstellen der Institution:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +// Institution aktualisieren +router.put('/institutions/:id', (req, res) => { + try { + const db = getDb(); + const stmt = db.prepare(` + UPDATE institutions SET + name = ?, type = ?, industry = ?, website = ?, logo_url = ?, + description = ?, + main_address = ?, main_postal_code = ?, main_city = ?, + main_state = ?, main_country = ?, + trade_register = ?, notes = ?, tags = ? + WHERE id = ? + `); + + stmt.run( + req.body.name, + req.body.type, + req.body.industry, + req.body.website, + req.body.logo_url, + req.body.description, + req.body.main_address, + req.body.main_postal_code, + req.body.main_city, + req.body.main_state, + req.body.main_country, + req.body.trade_register, + req.body.notes, + req.body.tags, + req.params.id + ); + + const institution = db.prepare('SELECT * FROM institutions WHERE id = ?').get(req.params.id); + + // Socket.io Event + req.app.get('io').to(`project-${institution.project_id}`).emit('institution:updated', institution); + + res.json({ success: true, data: institution }); + } catch (error) { + logger.error('Fehler beim Aktualisieren der Institution:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +// =================================== +// KONTAKTE (PERSONEN) +// =================================== + +// Alle Kontakte abrufen +router.get('/contacts', (req, res) => { + try { + const db = getDb(); + const { project_id, institution_id, search, is_active } = req.query; + let query = ` + SELECT c.*, i.name as institution_name + FROM contacts_extended c + LEFT JOIN institutions i ON c.institution_id = i.id + WHERE 1=1 + `; + const params = []; + + if (project_id) { + query += ' AND c.project_id = ?'; + params.push(project_id); + } + + if (institution_id) { + query += ' AND c.institution_id = ?'; + params.push(institution_id); + } + + if (search) { + query += ' AND (c.first_name LIKE ? OR c.last_name LIKE ? OR c.display_name LIKE ? OR c.tags LIKE ?)'; + const searchPattern = `%${search}%`; + params.push(searchPattern, searchPattern, searchPattern, searchPattern); + } + + if (is_active !== undefined) { + query += ' AND c.is_active = ?'; + params.push(is_active === 'true' ? 1 : 0); + } + + query += ' ORDER BY c.last_name, c.first_name ASC'; + + const contacts = db.prepare(query).all(...params); + res.json({ success: true, data: contacts }); + } catch (error) { + logger.error('Fehler beim Abrufen der Kontakte:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +// Kontakt mit allen Details abrufen +router.get('/contacts/:id/full', (req, res) => { + try { + const db = getDb(); + // Basis-Kontaktdaten + const contact = db.prepare(` + SELECT c.*, i.name as institution_name + FROM contacts_extended c + LEFT JOIN institutions i ON c.institution_id = i.id + WHERE c.id = ? + `).get(req.params.id); + + if (!contact) { + return res.status(404).json({ success: false, error: 'Kontakt nicht gefunden' }); + } + + // Kontaktdetails + contact.details = db.prepare(` + SELECT * FROM contact_details + WHERE contact_id = ? + ORDER BY is_primary DESC, type ASC + `).all(req.params.id); + + // Weitere Institutionen + contact.institutions = db.prepare(` + SELECT i.*, r.position, r.department, r.start_date, r.end_date, r.is_primary + FROM person_institution_relations r + JOIN institutions i ON r.institution_id = i.id + WHERE r.contact_id = ? + ORDER BY r.is_primary DESC, r.start_date DESC + `).all(req.params.id); + + // Kategorien + contact.categories = db.prepare(` + SELECT c.* + FROM contact_categories c + JOIN contact_category_assignments a ON c.id = a.category_id + WHERE a.contact_id = ? + `).all(req.params.id); + + // Letzte Interaktionen + contact.recent_interactions = db.prepare(` + SELECT * FROM contact_interactions + WHERE contact_id = ? + ORDER BY date DESC + LIMIT 10 + `).all(req.params.id); + + res.json({ success: true, data: contact }); + } catch (error) { + logger.error('Fehler beim Abrufen des Kontakts:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +// Kontakt erstellen +router.post('/contacts', (req, res) => { + // Basis-Validierung + if (!req.body.first_name || !req.body.last_name || !req.body.project_id) { + return res.status(400).json({ success: false, error: 'Vor- und Nachname sowie Projekt-ID sind erforderlich' }); + } + + const db = getDb(); + const transaction = db.transaction((data) => { + try { + // Kontakt erstellen + const stmt = db.prepare(` + INSERT INTO contacts_extended ( + salutation, title, first_name, last_name, display_name, + position, department, institution_id, + notes, tags, avatar_url, is_active, created_by, project_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + const result = stmt.run( + data.salutation || null, + data.title || null, + data.first_name, + data.last_name, + data.display_name || `${data.first_name} ${data.last_name}`, + data.position || null, + data.department || null, + data.institution_id || null, + data.notes || null, + data.tags || null, + data.avatar_url || null, + data.is_active !== false ? 1 : 0, + req.userId, + data.project_id + ); + + const contactId = result.lastInsertRowid; + + // Kontaktdetails hinzufügen + if (data.details && Array.isArray(data.details)) { + const detailStmt = db.prepare(` + INSERT INTO contact_details ( + contact_id, type, subtype, value, label, is_primary, is_public + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `); + + for (const detail of data.details) { + detailStmt.run( + contactId, + detail.type, + detail.subtype || null, + detail.value, + detail.label || null, + detail.is_primary ? 1 : 0, + detail.is_public !== false ? 1 : 0 + ); + } + } + + // Kategorien zuweisen + if (data.categories && Array.isArray(data.categories)) { + const categoryStmt = db.prepare(` + INSERT INTO contact_category_assignments (contact_id, category_id) + VALUES (?, ?) + `); + + for (const categoryId of data.categories) { + categoryStmt.run(contactId, categoryId); + } + } + + return contactId; + } catch (error) { + throw error; + } + }); + + try { + const contactId = transaction(req.body); + const contact = db.prepare('SELECT * FROM contacts_extended WHERE id = ?').get(contactId); + + // Socket.io Event + req.app.get('io').to(`project-${req.body.project_id}`).emit('contact:created', contact); + + res.status(201).json({ success: true, data: contact }); + } catch (error) { + logger.error('Fehler beim Erstellen des Kontakts:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +// =================================== +// KONTAKTDETAILS +// =================================== + +// Kontaktdetail hinzufügen +router.post('/contact-details', (req, res) => { + // Basis-Validierung + if (!req.body.type || !req.body.value) { + return res.status(400).json({ success: false, error: 'Typ und Wert sind erforderlich' }); + } + + try { + const db = getDb(); + const stmt = db.prepare(` + INSERT INTO contact_details ( + contact_id, institution_id, type, subtype, value, label, + is_primary, is_public, notes + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + const result = stmt.run( + req.body.contact_id || null, + req.body.institution_id || null, + req.body.type, + req.body.subtype || null, + req.body.value, + req.body.label || null, + req.body.is_primary ? 1 : 0, + req.body.is_public !== false ? 1 : 0, + req.body.notes || null + ); + + const detail = db.prepare('SELECT * FROM contact_details WHERE id = ?').get(result.lastInsertRowid); + + // Socket.io Event + const eventType = req.body.contact_id ? 'contact' : 'institution'; + req.app.get('io').emit(`${eventType}:detail:added`, detail); + + res.status(201).json({ success: true, data: detail }); + } catch (error) { + logger.error('Fehler beim Hinzufügen des Kontaktdetails:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +// =================================== +// PERSON-INSTITUTION BEZIEHUNGEN +// =================================== + +// Beziehung erstellen +router.post('/relations', (req, res) => { + // Basis-Validierung + if (!req.body.contact_id || !req.body.institution_id) { + return res.status(400).json({ success: false, error: 'Kontakt-ID und Institutions-ID sind erforderlich' }); + } + + try { + const db = getDb(); + const stmt = db.prepare(` + INSERT INTO person_institution_relations ( + contact_id, institution_id, position, department, + start_date, end_date, is_primary, notes + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + + const result = stmt.run( + req.body.contact_id, + req.body.institution_id, + req.body.position || null, + req.body.department || null, + req.body.start_date || null, + req.body.end_date || null, + req.body.is_primary ? 1 : 0, + req.body.notes || null + ); + + const relation = db.prepare('SELECT * FROM person_institution_relations WHERE id = ?') + .get(result.lastInsertRowid); + + // Socket.io Event + req.app.get('io').emit('relation:created', relation); + + res.status(201).json({ success: true, data: relation }); + } catch (error) { + logger.error('Fehler beim Erstellen der Beziehung:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +// =================================== +// KATEGORIEN +// =================================== + +// Alle Kategorien abrufen +router.get('/categories', (req, res) => { + try { + const db = getDb(); + const { project_id } = req.query; + let query = 'SELECT * FROM contact_categories'; + const params = []; + + if (project_id) { + query += ' WHERE project_id = ?'; + params.push(project_id); + } + + query += ' ORDER BY name ASC'; + + const categories = db.prepare(query).all(...params); + res.json({ success: true, data: categories }); + } catch (error) { + logger.error('Fehler beim Abrufen der Kategorien:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +// =================================== +// INTERAKTIONEN +// =================================== + +// Interaktion hinzufügen +router.post('/interactions', (req, res) => { + // Basis-Validierung + if (!req.body.type) { + return res.status(400).json({ success: false, error: 'Typ ist erforderlich' }); + } + + try { + const db = getDb(); + const stmt = db.prepare(` + INSERT INTO contact_interactions ( + contact_id, institution_id, type, subject, content, + date, duration_minutes, task_id, created_by + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + const result = stmt.run( + req.body.contact_id || null, + req.body.institution_id || null, + req.body.type, + req.body.subject || null, + req.body.content || null, + req.body.date || new Date().toISOString(), + req.body.duration_minutes || null, + req.body.task_id || null, + req.userId + ); + + const interaction = db.prepare('SELECT * FROM contact_interactions WHERE id = ?') + .get(result.lastInsertRowid); + + // Socket.io Event + req.app.get('io').emit('interaction:created', interaction); + + res.status(201).json({ success: true, data: interaction }); + } catch (error) { + logger.error('Fehler beim Hinzufügen der Interaktion:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index a453061..b937634 100644 --- a/backend/server.js +++ b/backend/server.js @@ -82,7 +82,7 @@ app.use(helmet({ // CORS app.use(cors({ - origin: true, + origin: process.env.CORS_ORIGIN || 'https://taskmate.aegis-sight.de', credentials: true })); @@ -97,14 +97,15 @@ app.use(cookieParser()); const { sanitizeMiddleware } = require('./middleware/validation'); app.use(sanitizeMiddleware); -// Request Logging +// Request Logging (nur API-Requests, keine statischen Assets) app.use((req, res, next) => { - const start = Date.now(); - res.on('finish', () => { - const duration = Date.now() - start; - // Use originalUrl to see the full path including /api prefix - logger.info(`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`); - }); + if (req.originalUrl.startsWith('/api/')) { + const start = Date.now(); + res.on('finish', () => { + const duration = Date.now() - start; + logger.info(`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`); + }); + } next(); }); @@ -140,18 +141,18 @@ app.use('/api/tasks', authenticateToken, csrfProtection, taskRoutes); app.use('/api/subtasks', authenticateToken, csrfProtection, subtaskRoutes); app.use('/api/comments', authenticateToken, csrfProtection, commentRoutes); app.use('/api/labels', authenticateToken, csrfProtection, labelRoutes); -app.use('/api/files', authenticateToken, fileRoutes); +app.use('/api/files', authenticateToken, csrfProtection, fileRoutes); app.use('/api/links', authenticateToken, csrfProtection, linkRoutes); app.use('/api/templates', authenticateToken, csrfProtection, templateRoutes); -app.use('/api/stats', authenticateToken, statsRoutes); -app.use('/api/export', authenticateToken, exportRoutes); +app.use('/api/stats', authenticateToken, csrfProtection, statsRoutes); +app.use('/api/export', authenticateToken, csrfProtection, exportRoutes); app.use('/api/import', authenticateToken, csrfProtection, importRoutes); -// Admin-Routes (eigene Auth-Middleware) -app.use('/api/admin', csrfProtection, adminRoutes); +// Admin-Routes +app.use('/api/admin', authenticateToken, csrfProtection, adminRoutes); -// Proposals-Routes (eigene Auth-Middleware) -app.use('/api/proposals', csrfProtection, proposalRoutes); +// Proposals-Routes +app.use('/api/proposals', authenticateToken, csrfProtection, proposalRoutes); // Notifications-Routes app.use('/api/notifications', authenticateToken, csrfProtection, notificationRoutes); diff --git a/frontend/css/contacts-modern.css b/frontend/css/contacts-modern.css new file mode 100644 index 0000000..12c2706 --- /dev/null +++ b/frontend/css/contacts-modern.css @@ -0,0 +1,765 @@ +/** + * TASKMATE - Modernes Kontaktmanagement Styles + * ============================================= + * Lead-Management inspiriertes Design + */ + +/* Container */ +.contacts-modern { + display: flex; + flex-direction: column; + height: 100%; + background: var(--bg-secondary); +} + +/* Header */ +.contacts-header { + background: var(--bg-card); + border-bottom: 1px solid var(--border-light); + padding: var(--spacing-4); + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--spacing-4); + flex-wrap: wrap; +} + +.contacts-title { + display: flex; + align-items: baseline; + gap: var(--spacing-3); +} + +.contacts-title h2 { + margin: 0; + font-size: var(--text-xl); + font-weight: var(--font-semibold); + color: var(--text-primary); +} + +.contact-count { + font-size: var(--text-sm); + color: var(--text-tertiary); +} + +/* Controls */ +.contacts-controls { + display: flex; + align-items: center; + gap: var(--spacing-3); + flex-wrap: wrap; +} + +.search-box { + position: relative; + min-width: 300px; +} + +.search-icon { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + color: var(--text-tertiary); + pointer-events: none; +} + +.search-input { + width: 100%; + padding: 8px 12px 8px 36px; + background: var(--bg-secondary); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + font-size: var(--text-sm); + transition: all var(--transition-fast); +} + +.search-input:focus { + outline: none; + border-color: var(--primary); + background: var(--bg-card); +} + +.filter-select { + padding: 8px 12px; + background: var(--bg-card); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + font-size: var(--text-sm); + cursor: pointer; +} + +/* List Container */ +.contacts-list-container { + flex: 1; + overflow-y: auto; + padding: var(--spacing-4); +} + +/* List Items */ +.contacts-list { + display: flex; + flex-direction: column; + gap: 1px; + background: var(--border-light); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.contact-list-item { + background: var(--bg-card); + padding: var(--spacing-3) var(--spacing-4); + display: flex; + align-items: center; + gap: var(--spacing-3); + cursor: pointer; + transition: all var(--transition-fast); +} + +.contact-list-item:hover { + background: var(--bg-hover); +} + +.contact-list-item:first-child { + border-radius: var(--radius-lg) var(--radius-lg) 0 0; +} + +.contact-list-item:last-child { + border-radius: 0 0 var(--radius-lg) var(--radius-lg); +} + +/* Avatar */ +.contact-avatar { + position: relative; + flex-shrink: 0; +} + +.contact-avatar img { + width: 48px; + height: 48px; + border-radius: var(--radius-full); + object-fit: cover; +} + +.avatar-initials { + width: 48px; + height: 48px; + border-radius: var(--radius-full); + background: var(--primary); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-weight: var(--font-medium); + font-size: var(--text-md); +} + +.avatar-icon { + width: 48px; + height: 48px; + border-radius: var(--radius-md); + background: var(--bg-tertiary); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-tertiary); +} + +.status-inactive { + position: absolute; + bottom: 0; + right: 0; + width: 12px; + height: 12px; + background: var(--danger); + border: 2px solid var(--bg-card); + border-radius: var(--radius-full); +} + +/* Contact Info */ +.contact-info { + flex: 1; + min-width: 0; +} + +.contact-name { + font-weight: var(--font-medium); + color: var(--text-primary); + margin-bottom: 2px; +} + +.contact-subtitle { + font-size: var(--text-sm); + color: var(--text-secondary); +} + +/* Contact Meta */ +.contact-meta { + font-size: var(--text-sm); + color: var(--text-tertiary); + margin-left: auto; +} + +/* Contact Actions */ +.contact-actions { + display: flex; + gap: var(--spacing-2); + opacity: 0; + transition: opacity var(--transition-fast); +} + +.contact-list-item:hover .contact-actions { + opacity: 1; +} + +/* Detail View */ +.contact-detail-view { + background: var(--bg-secondary); +} + +.detail-header { + background: var(--bg-card); + border-bottom: 1px solid var(--border-light); + padding: var(--spacing-4); + display: flex; + align-items: center; + gap: var(--spacing-3); +} + +.btn-back-to-list { + background: none; + border: none; + padding: var(--spacing-2); + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition-fast); +} + +.btn-back-to-list:hover { + color: var(--text-primary); + background: var(--bg-hover); +} + +.detail-title { + flex: 1; +} + +.detail-title h2 { + margin: 0; + font-size: var(--text-xl); + font-weight: var(--font-semibold); + color: var(--text-primary); +} + +.detail-title .subtitle { + font-size: var(--text-sm); + color: var(--text-secondary); +} + +.detail-actions { + display: flex; + gap: var(--spacing-2); +} + +/* Detail Content */ +.detail-content { + display: grid; + grid-template-columns: 1fr 320px; + gap: var(--spacing-4); + padding: var(--spacing-4); +} + +.detail-main { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.detail-section { + background: var(--bg-card); + border-radius: var(--radius-lg); + padding: var(--spacing-4); +} + +.detail-section h3 { + margin: 0 0 var(--spacing-3); + font-size: var(--text-lg); + font-weight: var(--font-semibold); + color: var(--text-primary); +} + +/* Detail Items */ +.detail-grid { + display: flex; + flex-direction: column; + gap: var(--spacing-3); +} + +.detail-item { + display: flex; + align-items: flex-start; + gap: var(--spacing-3); +} + +.detail-icon { + flex-shrink: 0; + width: 40px; + height: 40px; + border-radius: var(--radius-md); + background: var(--bg-secondary); + padding: 10px; + color: var(--text-secondary); +} + +.detail-content { + flex: 1; +} + +.detail-label { + font-size: var(--text-sm); + color: var(--text-tertiary); + margin-bottom: 2px; +} + +.detail-value { + color: var(--text-primary); +} + +/* Sidebar */ +.detail-sidebar { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.sidebar-section { + background: var(--bg-card); + border-radius: var(--radius-lg); + padding: var(--spacing-4); +} + +.sidebar-section h4 { + margin: 0 0 var(--spacing-3); + font-size: var(--text-md); + font-weight: var(--font-semibold); + color: var(--text-primary); +} + +.info-list { + display: grid; + gap: var(--spacing-2); +} + +.info-list dt { + font-size: var(--text-sm); + color: var(--text-tertiary); + margin-bottom: 2px; +} + +.info-list dd { + margin: 0 0 var(--spacing-3); + color: var(--text-primary); +} + +.info-list.small dt, +.info-list.small dd { + font-size: var(--text-xs); +} + +/* Tags */ +.tag-list { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-1); +} + +.tag { + background: var(--bg-tertiary); + color: var(--text-secondary); + padding: 4px 10px; + border-radius: var(--radius-sm); + font-size: var(--text-sm); +} + +/* Forms */ +.contact-type-selector { + display: flex; + gap: var(--spacing-3); + margin-bottom: var(--spacing-4); + padding: var(--spacing-3); + background: var(--bg-secondary); + border-radius: var(--radius-lg); +} + +.type-option { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-2); + padding: var(--spacing-3); + background: var(--bg-card); + border: 2px solid var(--border-light); + border-radius: var(--radius-md); + cursor: pointer; + transition: all var(--transition-fast); +} + +.type-option input { + display: none; +} + +.type-option.active { + border-color: var(--primary); + background: var(--primary-light); +} + +.type-option svg { + color: var(--text-secondary); +} + +.type-option.active svg { + color: var(--primary); +} + +/* Modern Form */ +.modern-contact-form { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.form-section { + background: var(--bg-secondary); + border-radius: var(--radius-lg); + padding: var(--spacing-4); +} + +.form-section h3 { + margin: 0 0 var(--spacing-3); + font-size: var(--text-md); + font-weight: var(--font-semibold); + color: var(--text-primary); +} + +/* Form Grid */ +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--spacing-3); +} + +.form-field { + display: flex; + flex-direction: column; + gap: var(--spacing-1); +} + +.form-field.small { + max-width: 150px; +} + +.form-field.large { + grid-column: span 2; +} + +.form-field.full { + grid-column: 1 / -1; +} + +.form-field label { + font-size: var(--text-sm); + font-weight: var(--font-medium); + color: var(--text-primary); +} + +/* Form Controls - Einheitliche Größe */ +.form-control { + padding: 10px 12px; + background: var(--bg-card); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + font-size: var(--text-sm); + font-family: inherit; + transition: all var(--transition-fast); + width: 100%; + box-sizing: border-box; +} + +.form-control:focus { + outline: none; + border-color: var(--primary); + background: var(--bg-card); + box-shadow: 0 0 0 3px rgba(var(--primary-rgb), 0.1); +} + +.form-control::placeholder { + color: var(--text-tertiary); +} + +/* Select einheitlich */ +select.form-control { + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-position: right 8px center; + background-size: 16px; + padding-right: 32px; +} + +/* Checkbox */ +.checkbox-label { + display: flex; + align-items: center; + gap: var(--spacing-2); + cursor: pointer; + font-size: var(--text-sm); + user-select: none; +} + +.checkbox-label input[type="checkbox"] { + width: 18px; + height: 18px; + flex-shrink: 0; + cursor: pointer; +} + +/* Contact Details Container */ +.contact-details-container { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.empty-details-prompt { + text-align: center; + padding: var(--spacing-4); + background: var(--bg-card); + border-radius: var(--radius-md); + border: 2px dashed var(--border-light); +} + +.empty-details-prompt p { + margin: 0 0 var(--spacing-3); + color: var(--text-secondary); +} + +/* Detail Type Buttons */ +.detail-type-buttons { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-2); + justify-content: center; +} + +.btn-add-detail { + display: inline-flex; + align-items: center; + gap: var(--spacing-1); + padding: var(--spacing-2) var(--spacing-3); + background: var(--bg-card); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + font-size: var(--text-sm); + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition-fast); +} + +.btn-add-detail:hover { + background: var(--primary); + border-color: var(--primary); + color: white; +} + +.detail-type-buttons.small .btn-add-detail { + padding: 6px 10px; + font-size: var(--text-xs); +} + +/* Detail Groups */ +.detail-group { + display: flex; + flex-direction: column; + gap: var(--spacing-2); +} + +.detail-group h4 { + margin: 0; + font-size: var(--text-sm); + font-weight: var(--font-semibold); + color: var(--text-primary); +} + +/* Detail Field Group */ +.detail-field-group { + background: var(--bg-card); + border: 1px solid var(--border-light); + border-radius: var(--radius-md); + padding: var(--spacing-3); +} + +.detail-field-row { + display: grid; + grid-template-columns: 140px 1fr auto auto; + gap: var(--spacing-2); + align-items: center; +} + +.detail-field-row:not(:last-child) { + margin-bottom: var(--spacing-2); +} + +.detail-field-row:last-child { + grid-template-columns: 1fr; +} + +/* Kleine Select-Boxen */ +.form-control.small { + max-width: 140px; +} + +/* Remove Button */ +.btn-remove { + background: var(--bg-secondary); + border: 1px solid var(--border-light); + color: var(--text-tertiary); +} + +.btn-remove:hover { + background: var(--danger); + border-color: var(--danger); + color: white; +} + +/* Detail Add Section */ +.detail-add-section { + padding: var(--spacing-3); + background: var(--bg-card); + border-radius: var(--radius-md); + text-align: center; +} + +.detail-add-section p { + margin: 0 0 var(--spacing-2); +} + +/* Institution Card */ +.institution-card { + background: var(--bg-secondary); + border-radius: var(--radius-md); + padding: var(--spacing-3); + margin-bottom: var(--spacing-2); +} + +.institution-card h4 { + margin: 0 0 var(--spacing-1); + font-size: var(--text-md); + font-weight: var(--font-medium); +} + +.institution-card p { + margin: 0; + font-size: var(--text-sm); + color: var(--text-secondary); +} + +/* Empty State */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--spacing-8); + text-align: center; +} + +.empty-state svg { + color: var(--text-tertiary); + margin-bottom: var(--spacing-4); +} + +.empty-state h3 { + margin: 0 0 var(--spacing-2); + font-size: var(--text-lg); + font-weight: var(--font-semibold); + color: var(--text-primary); +} + +.empty-state p { + margin: 0; + color: var(--text-secondary); +} + +/* Text Utilities */ +.text-muted { + color: var(--text-tertiary); +} + +/* Icons */ +.btn-icon { + width: 32px; + height: 32px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + background: none; + border: none; + border-radius: var(--radius-md); + cursor: pointer; + transition: all var(--transition-fast); +} + +.btn-icon:hover { + background: var(--bg-hover); +} + +/* Responsive */ +@media (max-width: 1024px) { + .detail-content { + grid-template-columns: 1fr; + } + + .detail-sidebar { + order: -1; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + } +} + +@media (max-width: 768px) { + .contacts-header { + flex-direction: column; + align-items: stretch; + } + + .contacts-controls { + flex-direction: column; + } + + .search-box { + min-width: auto; + width: 100%; + } + + .form-grid { + grid-template-columns: 1fr; + } + + .form-field.small, + .form-field.large { + max-width: none; + grid-column: 1; + } + + .detail-field-row { + grid-template-columns: 1fr; + gap: var(--spacing-2); + } + + .detail-field-row > * { + width: 100% !important; + } +} \ No newline at end of file diff --git a/frontend/css/dashboard.css b/frontend/css/dashboard.css deleted file mode 100644 index d00f249..0000000 --- a/frontend/css/dashboard.css +++ /dev/null @@ -1,291 +0,0 @@ -/** - * TASKMATE - Dashboard Styles - * =========================== - * Dashboard, Stats, Charts - Modernes Light Theme - */ - -/* ======================================== - DASHBOARD VIEW - ======================================== */ - -.view-dashboard { - padding: var(--spacing-6); - gap: var(--spacing-6); - overflow-y: auto; - background: var(--bg-main); -} - -/* Stats Grid */ -.stats-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: var(--spacing-5); -} - -.stat-card { - display: flex; - align-items: center; - gap: var(--spacing-4); - padding: var(--spacing-5); - background: var(--bg-card); - border: 1px solid var(--border-light); - border-radius: var(--radius-xl); - box-shadow: var(--shadow-sm); - transition: all var(--transition-default); -} - -.stat-card:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-md); - border-color: var(--border-default); -} - -.stat-card.stat-danger { - border-left: 4px solid var(--error); -} - -.stat-icon { - display: flex; - align-items: center; - justify-content: center; - width: 52px; - height: 52px; - border-radius: var(--radius-xl); -} - -.stat-icon svg { - width: 26px; - height: 26px; -} - -.stat-icon.stat-open { - background: var(--primary-light); - color: var(--primary); -} - -.stat-icon.stat-progress { - background: var(--warning-bg); - color: var(--warning); -} - -.stat-icon.stat-done { - background: var(--success-bg); - color: var(--success); -} - -.stat-icon.stat-overdue { - background: var(--error-bg); - color: var(--error); -} - -.stat-info { - display: flex; - flex-direction: column; -} - -.stat-value { - font-family: var(--font-primary); - font-size: var(--text-2xl); - font-weight: var(--font-bold); - color: var(--text-primary); - line-height: 1; -} - -.stat-label { - font-size: var(--text-sm); - color: var(--text-tertiary); - margin-top: var(--spacing-1); -} - -/* Dashboard Row */ -.dashboard-row { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: var(--spacing-5); -} - -/* Dashboard Card */ -.dashboard-card { - padding: var(--spacing-5); - background: var(--bg-card); - border: 1px solid var(--border-light); - border-radius: var(--radius-xl); - box-shadow: var(--shadow-sm); -} - -.dashboard-card h3 { - font-size: var(--text-base); - font-weight: var(--font-semibold); - margin-bottom: var(--spacing-4); - color: var(--text-primary); -} - -/* Chart Container */ -.chart-container { - height: 200px; - position: relative; -} - -/* Simple Bar Chart */ -.bar-chart { - display: flex; - align-items: flex-end; - justify-content: space-around; - height: 100%; - padding-top: var(--spacing-4); -} - -.bar-item { - display: flex; - flex-direction: column; - align-items: center; - gap: var(--spacing-1); - flex: 1; -} - -.bar { - width: 36px; - background: var(--primary); - border-radius: var(--radius-md) var(--radius-md) 0 0; - transition: all var(--transition-default); - min-height: 4px; -} - -.bar:hover { - background: var(--primary-hover); - transform: scaleY(1.05); -} - -.bar-label { - font-size: var(--text-xs); - color: var(--text-muted); -} - -.bar-value { - font-size: var(--text-sm); - font-weight: var(--font-semibold); - color: var(--text-primary); -} - -/* Horizontal Bar Chart */ -.horizontal-bar-chart { - display: flex; - flex-direction: column; - gap: var(--spacing-4); -} - -.horizontal-bar-item { - display: flex; - flex-direction: column; - gap: var(--spacing-1); -} - -.horizontal-bar-header { - display: flex; - justify-content: space-between; - font-size: var(--text-sm); -} - -.horizontal-bar-label { - color: var(--text-primary); - font-weight: var(--font-medium); -} - -.horizontal-bar-value { - color: var(--text-muted); -} - -.horizontal-bar { - height: 20px; - background: var(--bg-tertiary); - border-radius: var(--radius-full); - overflow: hidden; -} - -.horizontal-bar-fill { - height: 100%; - background: var(--primary); - border-radius: var(--radius-full); - transition: width var(--transition-default); -} - -/* Due Today List */ -.due-today-list { - display: flex; - flex-direction: column; - gap: var(--spacing-3); -} - -.due-today-item { - display: flex; - align-items: center; - gap: var(--spacing-4); - padding: var(--spacing-4); - background: var(--bg-tertiary); - border-radius: var(--radius-xl); - cursor: pointer; - transition: all var(--transition-fast); -} - -.due-today-item:hover { - background: var(--bg-hover); - transform: translateX(4px); -} - -.due-today-priority { - width: 10px; - height: 10px; - border-radius: var(--radius-full); - flex-shrink: 0; -} - -.due-today-priority.high { background: var(--priority-high); } -.due-today-priority.medium { background: var(--priority-medium); } -.due-today-priority.low { background: var(--priority-low); } - -.due-today-title { - flex: 1; - font-weight: var(--font-medium); - color: var(--text-primary); -} - -.due-today-assignee { - display: flex; - align-items: center; - gap: var(--spacing-2); - font-size: var(--text-sm); - color: var(--text-tertiary); -} - -/* ======================================== - LIST VIEW - ======================================== */ - -.view-list { - padding: var(--spacing-6); - background: var(--bg-main); -} - -.list-container { - background: var(--bg-card); - border: 1px solid var(--border-light); - border-radius: var(--radius-xl); - box-shadow: var(--shadow-sm); - overflow: hidden; -} - -/* Bulk Actions */ -.bulk-actions { - display: flex; - align-items: center; - gap: var(--spacing-4); - padding: var(--spacing-4) var(--spacing-5); - background: var(--primary-light); - border-top: 1px solid var(--border-default); -} - -.selected-count { - font-size: var(--text-sm); - font-weight: var(--font-semibold); - color: var(--primary); -} diff --git a/frontend/index.html b/frontend/index.html index 8c5d1a6..91806b2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -38,7 +38,6 @@ - diff --git a/frontend/js/auth-fix.js b/frontend/js/auth-fix.js new file mode 100644 index 0000000..67432a6 --- /dev/null +++ b/frontend/js/auth-fix.js @@ -0,0 +1,103 @@ +/** + * TASKMATE - Auth Fix + * ==================== + * Behebt Authentifizierungs-Probleme + */ + +// Überwache alle API-Anfragen +const originalFetch = window.fetch; +window.fetch = async function(...args) { + try { + const response = await originalFetch.apply(this, args); + + // Bei 401: Automatisches Token-Refresh versuchen + if (response.status === 401 && !args[0].includes('/auth/login')) { + console.log('[AuthFix] 401 erkannt, versuche Token-Refresh...'); + + const token = localStorage.getItem('token'); + const refreshToken = localStorage.getItem('refreshToken'); + + if (token) { + try { + // Versuche Token-Refresh + const refreshResponse = await originalFetch('/api/auth/refresh', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ refreshToken: refreshToken || token }) + }); + + if (refreshResponse.ok) { + const data = await refreshResponse.json(); + console.log('[AuthFix] Token erfolgreich erneuert'); + + // Neue Tokens speichern + localStorage.setItem('token', data.token); + if (data.refreshToken) { + localStorage.setItem('refreshToken', data.refreshToken); + } + + // Original-Request mit neuem Token wiederholen + if (args[1] && args[1].headers) { + args[1].headers['Authorization'] = `Bearer ${data.token}`; + } + + return originalFetch.apply(this, args); + } + } catch (error) { + console.error('[AuthFix] Token-Refresh fehlgeschlagen:', error); + } + } + } + + return response; + } catch (error) { + throw error; + } +}; + +// Stelle sicher, dass das Token bei jedem Request aktuell ist +document.addEventListener('DOMContentLoaded', () => { + console.log('[AuthFix] Auth-Fix geladen'); + + // Prüfe Token-Gültigkeit alle 5 Minuten + setInterval(() => { + const token = localStorage.getItem('token'); + if (token) { + // Dekodiere JWT um Ablaufzeit zu prüfen + try { + const payload = JSON.parse(atob(token.split('.')[1])); + const expiresIn = payload.exp * 1000 - Date.now(); + + // Wenn Token in weniger als 10 Minuten abläuft, erneuere es + if (expiresIn < 10 * 60 * 1000) { + console.log('[AuthFix] Token läuft bald ab, erneuere...'); + fetch('/api/auth/refresh', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ refreshToken: localStorage.getItem('refreshToken') || token }) + }).then(response => { + if (response.ok) { + return response.json(); + } + }).then(data => { + if (data && data.token) { + localStorage.setItem('token', data.token); + if (data.refreshToken) { + localStorage.setItem('refreshToken', data.refreshToken); + } + console.log('[AuthFix] Token automatisch erneuert'); + } + }); + } + } catch (error) { + console.error('[AuthFix] Fehler beim Token-Check:', error); + } + } + }, 5 * 60 * 1000); // Alle 5 Minuten +}); \ No newline at end of file diff --git a/frontend/js/dashboard.js b/frontend/js/dashboard.js deleted file mode 100644 index bc6de9a..0000000 --- a/frontend/js/dashboard.js +++ /dev/null @@ -1,335 +0,0 @@ -/** - * TASKMATE - Dashboard Module - * =========================== - * Statistics and overview dashboard - */ - -import store from './store.js'; -import api from './api.js'; -import { - $, $$, createElement, clearElement, formatDate, getDueDateStatus, - getInitials -} from './utils.js'; - -class DashboardManager { - constructor() { - this.container = null; - this.stats = null; - this.completionData = null; - this.timeData = null; - - this.init(); - } - - init() { - this.container = $('#view-dashboard'); - - // Subscribe to store changes - store.subscribe('currentView', (view) => { - if (view === 'dashboard') this.loadAndRender(); - }); - - store.subscribe('currentProjectId', () => { - if (store.get('currentView') === 'dashboard') { - this.loadAndRender(); - } - }); - } - - // ===================== - // DATA LOADING - // ===================== - - async loadAndRender() { - if (store.get('currentView') !== 'dashboard') return; - - store.setLoading(true); - - try { - const projectId = store.get('currentProjectId'); - - const [stats, completionData, timeData] = await Promise.all([ - api.getStats(projectId), - api.getCompletionStats(projectId, 8), - api.getTimeStats(projectId) - ]); - - this.stats = stats; - this.completionData = completionData; - this.timeData = timeData; - // Due today tasks come from dashboard stats - this.dueTodayTasks = stats.dueToday || []; - // Overdue list - we only have the count, not individual tasks - this.overdueTasks = []; - - this.render(); - } catch (error) { - console.error('Failed to load dashboard data:', error); - this.showError('Fehler beim Laden der Dashboard-Daten'); - } finally { - store.setLoading(false); - } - } - - // ===================== - // RENDERING - // ===================== - - render() { - this.renderStats(); - this.renderCompletionChart(); - this.renderTimeChart(); - this.renderDueTodayList(); - this.renderOverdueList(); - } - - renderStats() { - if (!this.stats) return; - - // Open tasks - this.updateStatCard('stat-open', this.stats.open || 0); - - // In progress - this.updateStatCard('stat-progress', this.stats.inProgress || 0); - - // Completed - this.updateStatCard('stat-done', this.stats.completed || 0); - - // Overdue - this.updateStatCard('stat-overdue', this.stats.overdue || 0); - } - - updateStatCard(id, value) { - const valueEl = $(`#${id}`); - if (valueEl) { - valueEl.textContent = value.toString(); - } - } - - renderCompletionChart() { - const container = $('#chart-completed'); - if (!container || !this.completionData) return; - - clearElement(container); - - // Add bar-chart class - container.classList.add('bar-chart'); - - const maxValue = Math.max(...this.completionData.map(d => d.count), 1); - - this.completionData.forEach(item => { - const percentage = (item.count / maxValue) * 100; - - const barItem = createElement('div', { className: 'bar-item' }, [ - createElement('span', { className: 'bar-value' }, [item.count.toString()]), - createElement('div', { - className: 'bar', - style: { height: `${Math.max(percentage, 5)}%` } - }), - createElement('span', { className: 'bar-label' }, [item.label || item.week]) - ]); - - container.appendChild(barItem); - }); - } - - renderTimeChart() { - const container = $('#chart-time'); - if (!container || !this.timeData) return; - - clearElement(container); - - // Add horizontal-bar-chart class - container.classList.add('horizontal-bar-chart'); - - const totalTime = this.timeData.reduce((sum, item) => sum + (item.totalMinutes || 0), 0); - - this.timeData.slice(0, 5).forEach(item => { - const percentage = totalTime > 0 ? ((item.totalMinutes || 0) / totalTime) * 100 : 0; - - const barItem = createElement('div', { className: 'horizontal-bar-item' }, [ - createElement('div', { className: 'horizontal-bar-header' }, [ - createElement('span', { className: 'horizontal-bar-label' }, [item.name || item.projectName]), - createElement('span', { className: 'horizontal-bar-value' }, [ - this.formatMinutes(item.totalMinutes || 0) - ]) - ]), - createElement('div', { className: 'horizontal-bar' }, [ - createElement('div', { - className: 'horizontal-bar-fill', - style: { width: `${percentage}%` } - }) - ]) - ]); - - container.appendChild(barItem); - }); - - if (this.timeData.length === 0) { - container.appendChild(createElement('p', { - className: 'text-secondary', - style: { textAlign: 'center' } - }, ['Keine Zeitdaten verfügbar'])); - } - } - - renderDueTodayList() { - const container = $('#due-today-list'); - if (!container) return; - - clearElement(container); - - if (!this.dueTodayTasks || this.dueTodayTasks.length === 0) { - container.appendChild(createElement('p', { - className: 'text-secondary empty-message' - }, ['Keine Aufgaben für heute'])); - return; - } - - this.dueTodayTasks.slice(0, 5).forEach(task => { - const hasAssignee = task.assignedTo || task.assignedName; - const item = createElement('div', { - className: 'due-today-item', - onclick: () => this.openTaskModal(task.id) - }, [ - createElement('span', { - className: 'due-today-priority', - style: { backgroundColor: this.getPriorityColor(task.priority) } - }), - createElement('span', { className: 'due-today-title' }, [task.title]), - hasAssignee ? createElement('div', { className: 'due-today-assignee' }, [ - createElement('span', { - className: 'avatar avatar-sm', - style: { backgroundColor: task.assignedColor || '#888' } - }, [getInitials(task.assignedName || 'U')]) - ]) : null - ].filter(Boolean)); - - container.appendChild(item); - }); - - if (this.dueTodayTasks.length > 5) { - container.appendChild(createElement('button', { - className: 'btn btn-ghost btn-sm btn-block', - onclick: () => this.showAllDueToday() - }, [`Alle ${this.dueTodayTasks.length} anzeigen`])); - } - } - - renderOverdueList() { - const container = $('#overdue-list'); - if (!container) return; - - clearElement(container); - - if (!this.overdueTasks || this.overdueTasks.length === 0) { - container.appendChild(createElement('p', { - className: 'text-secondary empty-message' - }, ['Keine überfälligen Aufgaben'])); - return; - } - - this.overdueTasks.slice(0, 5).forEach(task => { - const daysOverdue = this.getDaysOverdue(task.dueDate); - - const item = createElement('div', { - className: 'due-today-item overdue-item', - onclick: () => this.openTaskModal(task.id) - }, [ - createElement('span', { - className: 'due-today-priority', - style: { backgroundColor: this.getPriorityColor(task.priority) } - }), - createElement('div', { style: { flex: 1 } }, [ - createElement('span', { className: 'due-today-title' }, [task.title]), - createElement('span', { - className: 'text-error', - style: { fontSize: 'var(--text-xs)', display: 'block' } - }, [`${daysOverdue} Tag(e) überfällig`]) - ]), - task.assignee ? createElement('div', { className: 'due-today-assignee' }, [ - createElement('span', { - className: 'avatar avatar-sm', - style: { backgroundColor: task.assignee.color || '#888' } - }, [task.assignee.initials || getInitials(task.assignee.username || '??')]) - ]) : null - ].filter(Boolean)); - - container.appendChild(item); - }); - - if (this.overdueTasks.length > 5) { - container.appendChild(createElement('button', { - className: 'btn btn-ghost btn-sm btn-block', - onclick: () => this.showAllOverdue() - }, [`Alle ${this.overdueTasks.length} anzeigen`])); - } - } - - // ===================== - // ACTIONS - // ===================== - - openTaskModal(taskId) { - window.dispatchEvent(new CustomEvent('modal:open', { - detail: { - modalId: 'task-modal', - mode: 'edit', - data: { taskId } - } - })); - } - - showAllDueToday() { - // Switch to list view with due date filter - store.setFilter('dueDate', 'today'); - store.setCurrentView('list'); - } - - showAllOverdue() { - // Switch to list view with overdue filter - store.setFilter('dueDate', 'overdue'); - store.setCurrentView('list'); - } - - // ===================== - // HELPERS - // ===================== - - formatMinutes(minutes) { - const hours = Math.floor(minutes / 60); - const mins = minutes % 60; - - if (hours > 0) { - return `${hours}h ${mins}m`; - } - return `${mins}m`; - } - - getPriorityColor(priority) { - const colors = { - high: 'var(--priority-high)', - medium: 'var(--priority-medium)', - low: 'var(--priority-low)' - }; - return colors[priority] || colors.medium; - } - - getDaysOverdue(dueDate) { - const due = new Date(dueDate); - const today = new Date(); - const diffTime = today - due; - return Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - } - - showError(message) { - window.dispatchEvent(new CustomEvent('toast:show', { - detail: { message, type: 'error' } - })); - } -} - -// Create and export singleton -const dashboardManager = new DashboardManager(); - -export default dashboardManager; diff --git a/frontend/js/mobile-swipe.js b/frontend/js/mobile-swipe.js new file mode 100644 index 0000000..ea9ed15 --- /dev/null +++ b/frontend/js/mobile-swipe.js @@ -0,0 +1,274 @@ +/** + * TASKMATE - Mobile Swipe Enhancement + * ==================================== + * Neue Swipe-Funktionalität für bessere mobile Navigation + */ + +export function enhanceMobileSwipe(mobileManager) { + const SWIPE_THRESHOLD = 50; + const SWIPE_VELOCITY = 0.3; + + // State für Column-Navigation + let currentColumnIndex = 0; + let columnCount = 0; + let isColumnSwipeEnabled = false; + + // Column indicator elements + let columnIndicator = null; + + /** + * Initialize column swipe for board view + */ + function initColumnSwipe() { + const boardContainer = document.querySelector('.board-container'); + if (!boardContainer || mobileManager.currentView !== 'board') return; + + // Create column indicator + if (!columnIndicator) { + columnIndicator = document.createElement('div'); + columnIndicator.className = 'mobile-column-indicator'; + columnIndicator.innerHTML = ` +
+ + `; + document.querySelector('.view-board')?.appendChild(columnIndicator); + } + + updateColumnInfo(); + showCurrentColumn(); + } + + /** + * Update column information + */ + function updateColumnInfo() { + const columns = document.querySelectorAll('.column'); + columnCount = columns.length; + + // Update dots + const dotsContainer = columnIndicator?.querySelector('.column-dots'); + if (dotsContainer) { + dotsContainer.innerHTML = Array.from({ length: columnCount }, (_, i) => + `` + ).join(''); + } + + // Update column name + const nameContainer = columnIndicator?.querySelector('.column-name'); + if (nameContainer && columns[currentColumnIndex]) { + const columnTitle = columns[currentColumnIndex].querySelector('.column-title')?.textContent || ''; + nameContainer.textContent = columnTitle; + } + } + + /** + * Show specific column (hide others) + */ + function showCurrentColumn() { + const columns = document.querySelectorAll('.column'); + const boardContainer = document.querySelector('.board-container'); + + columns.forEach((col, index) => { + if (index === currentColumnIndex) { + col.style.display = 'flex'; + col.classList.add('mobile-active'); + } else { + col.style.display = 'none'; + col.classList.remove('mobile-active'); + } + }); + + // Update add column button + const addColumnBtn = document.querySelector('.btn-add-column'); + if (addColumnBtn) { + addColumnBtn.style.display = currentColumnIndex === columnCount - 1 ? 'flex' : 'none'; + } + + updateColumnInfo(); + } + + /** + * Navigate to specific column + */ + function navigateToColumn(index) { + if (index < 0 || index >= columnCount) return; + + currentColumnIndex = index; + showCurrentColumn(); + + // Haptic feedback + if (navigator.vibrate) { + navigator.vibrate(10); + } + } + + /** + * Enhanced board swipe handler + */ + mobileManager.handleBoardSwipeEnd = function() { + if (!this.isSwiping || this.swipeDirection !== 'horizontal' || this.swipeTarget !== 'board') { + this.resetSwipe(); + return; + } + + const deltaX = this.touchCurrentX - this.touchStartX; + const deltaTime = Date.now() - this.touchStartTime; + const velocity = Math.abs(deltaX) / deltaTime; + + const isValidSwipe = Math.abs(deltaX) > SWIPE_THRESHOLD || velocity > SWIPE_VELOCITY; + + if (isValidSwipe && isColumnSwipeEnabled) { + if (deltaX > 0 && currentColumnIndex > 0) { + // Swipe right - previous column + navigateToColumn(currentColumnIndex - 1); + } else if (deltaX < 0 && currentColumnIndex < columnCount - 1) { + // Swipe left - next column + navigateToColumn(currentColumnIndex + 1); + } + } + + this.resetSwipe(); + }; + + /** + * View hint for header swipes + */ + let viewHint = null; + + function showViewSwipeHint(viewName, direction) { + if (!viewHint) { + viewHint = document.createElement('div'); + viewHint.className = 'mobile-view-hint'; + document.body.appendChild(viewHint); + } + + viewHint.textContent = getViewDisplayName(viewName); + viewHint.classList.add('visible', direction); + } + + function hideViewSwipeHint() { + if (viewHint) { + viewHint.classList.remove('visible', 'left', 'right'); + } + } + + function getViewDisplayName(view) { + const names = { + 'board': 'Board', + 'list': 'Liste', + 'calendar': 'Kalender', + 'proposals': 'Genehmigungen', + 'gitea': 'Gitea', + 'knowledge': 'Wissen' + }; + return names[view] || view; + } + + /** + * Enhanced header swipe handler + */ + mobileManager.handleHeaderSwipeMove = function(e) { + if (!this.isMobile || this.touchStartX === 0 || this.swipeTarget !== 'header') return; + + const touch = e.touches[0]; + this.touchCurrentX = touch.clientX; + this.touchCurrentY = touch.clientY; + + const deltaX = this.touchCurrentX - this.touchStartX; + const deltaY = this.touchCurrentY - this.touchStartY; + + // Determine direction + if (!this.swipeDirection && (Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10)) { + if (Math.abs(deltaX) > Math.abs(deltaY) * 1.5) { + this.swipeDirection = 'horizontal'; + this.isSwiping = true; + } else { + this.swipeDirection = 'vertical'; + this.resetSwipe(); + return; + } + } + + if (this.swipeDirection !== 'horizontal') return; + e.preventDefault(); + + // Show view hints + const currentIndex = this.viewOrder.indexOf(this.currentView); + if (deltaX > SWIPE_THRESHOLD && currentIndex > 0) { + showViewSwipeHint(this.viewOrder[currentIndex - 1], 'left'); + } else if (deltaX < -SWIPE_THRESHOLD && currentIndex < this.viewOrder.length - 1) { + showViewSwipeHint(this.viewOrder[currentIndex + 1], 'right'); + } else { + hideViewSwipeHint(); + } + }; + + mobileManager.handleHeaderSwipeEnd = function() { + if (!this.isSwiping || this.swipeDirection !== 'horizontal' || this.swipeTarget !== 'header') { + this.resetSwipe(); + hideViewSwipeHint(); + return; + } + + const deltaX = this.touchCurrentX - this.touchStartX; + const deltaTime = Date.now() - this.touchStartTime; + const velocity = Math.abs(deltaX) / deltaTime; + + const isValidSwipe = Math.abs(deltaX) > SWIPE_THRESHOLD || velocity > SWIPE_VELOCITY; + + if (isValidSwipe) { + const currentIndex = this.viewOrder.indexOf(this.currentView); + if (deltaX > 0 && currentIndex > 0) { + // Swipe right - previous view + this.switchView(this.viewOrder[currentIndex - 1]); + } else if (deltaX < 0 && currentIndex < this.viewOrder.length - 1) { + // Swipe left - next view + this.switchView(this.viewOrder[currentIndex + 1]); + } + } + + hideViewSwipeHint(); + this.resetSwipe(); + }; + + // Listen for view changes + document.addEventListener('view:changed', (e) => { + if (e.detail?.view === 'board' && mobileManager.isMobile) { + isColumnSwipeEnabled = true; + setTimeout(initColumnSwipe, 100); + } else { + isColumnSwipeEnabled = false; + if (columnIndicator) { + columnIndicator.style.display = 'none'; + } + } + }); + + // Listen for column updates + document.addEventListener('columns:updated', () => { + if (isColumnSwipeEnabled) { + updateColumnInfo(); + showCurrentColumn(); + } + }); + + // Update on resize + window.addEventListener('resize', () => { + if (mobileManager.isMobile && mobileManager.currentView === 'board') { + if (!isColumnSwipeEnabled) { + isColumnSwipeEnabled = true; + initColumnSwipe(); + } + } else { + isColumnSwipeEnabled = false; + // Show all columns on desktop + document.querySelectorAll('.column').forEach(col => { + col.style.display = ''; + col.classList.remove('mobile-active'); + }); + if (columnIndicator) { + columnIndicator.style.display = 'none'; + } + } + }); +} \ No newline at end of file diff --git a/frontend/js/tour.js b/frontend/js/tour.js deleted file mode 100644 index e784dce..0000000 --- a/frontend/js/tour.js +++ /dev/null @@ -1,325 +0,0 @@ -/** - * TASKMATE - Tour/Onboarding Module - * ================================== - * First-time user onboarding tour - */ - -import { $, createElement } from './utils.js'; - -class TourManager { - constructor() { - this.currentStep = 0; - this.isActive = false; - this.overlay = null; - this.tooltip = null; - - this.steps = [ - { - target: '.view-tabs', - title: 'Ansichten', - content: 'Wechseln Sie zwischen Board-, Listen-, Kalender- und Dashboard-Ansicht.', - position: 'bottom' - }, - { - target: '.project-selector', - title: 'Projekte', - content: 'Wählen Sie ein Projekt aus oder erstellen Sie ein neues.', - position: 'bottom' - }, - { - target: '.column', - title: 'Spalten', - content: 'Spalten repräsentieren den Status Ihrer Aufgaben. Ziehen Sie Aufgaben zwischen Spalten, um den Status zu ändern.', - position: 'right' - }, - { - target: '.btn-add-task', - title: 'Neue Aufgabe', - content: 'Klicken Sie hier, um eine neue Aufgabe zu erstellen.', - position: 'top' - }, - { - target: '.filter-bar', - title: 'Filter', - content: 'Filtern Sie Aufgaben nach Priorität, Bearbeiter oder Fälligkeitsdatum.', - position: 'bottom' - }, - { - target: '#search-input', - title: 'Suche', - content: 'Durchsuchen Sie alle Aufgaben nach Titel oder Beschreibung. Tipp: Drücken Sie "/" für schnellen Zugriff.', - position: 'bottom' - }, - { - target: '.user-menu', - title: 'Benutzermenu', - content: 'Hier können Sie Ihr Passwort ändern oder sich abmelden.', - position: 'bottom-left' - }, - { - target: '#theme-toggle', - title: 'Design', - content: 'Wechseln Sie zwischen hellem und dunklem Design.', - position: 'bottom-left' - } - ]; - - this.bindEvents(); - } - - bindEvents() { - window.addEventListener('tour:start', () => this.start()); - window.addEventListener('tour:stop', () => this.stop()); - - // Keyboard navigation - document.addEventListener('keydown', (e) => { - if (!this.isActive) return; - - if (e.key === 'Escape') { - this.stop(); - } else if (e.key === 'ArrowRight' || e.key === 'Enter') { - this.next(); - } else if (e.key === 'ArrowLeft') { - this.previous(); - } - }); - } - - // ===================== - // TOUR CONTROL - // ===================== - - start() { - // Check if tour was already completed - if (localStorage.getItem('tour_completed') === 'true') { - return; - } - - this.isActive = true; - this.currentStep = 0; - - this.createOverlay(); - this.showStep(); - } - - stop() { - this.isActive = false; - - if (this.overlay) { - this.overlay.remove(); - this.overlay = null; - } - - if (this.tooltip) { - this.tooltip.remove(); - this.tooltip = null; - } - - // Remove highlight from any element - $$('.tour-highlight')?.forEach(el => el.classList.remove('tour-highlight')); - } - - complete() { - localStorage.setItem('tour_completed', 'true'); - this.stop(); - - window.dispatchEvent(new CustomEvent('toast:show', { - detail: { - message: 'Tour abgeschlossen! Viel Erfolg mit TaskMate.', - type: 'success' - } - })); - } - - next() { - if (this.currentStep < this.steps.length - 1) { - this.currentStep++; - this.showStep(); - } else { - this.complete(); - } - } - - previous() { - if (this.currentStep > 0) { - this.currentStep--; - this.showStep(); - } - } - - skip() { - localStorage.setItem('tour_completed', 'true'); - this.stop(); - } - - // ===================== - // UI CREATION - // ===================== - - createOverlay() { - this.overlay = createElement('div', { - className: 'onboarding-overlay' - }); - - document.body.appendChild(this.overlay); - } - - showStep() { - const step = this.steps[this.currentStep]; - const targetElement = $(step.target); - - if (!targetElement) { - // Skip to next step if target not found - this.next(); - return; - } - - // Remove previous highlight - $$('.tour-highlight')?.forEach(el => el.classList.remove('tour-highlight')); - - // Highlight current target - targetElement.classList.add('tour-highlight'); - - // Position and show tooltip - this.showTooltip(step, targetElement); - } - - showTooltip(step, targetElement) { - // Remove existing tooltip - if (this.tooltip) { - this.tooltip.remove(); - } - - // Create tooltip - this.tooltip = createElement('div', { - className: 'onboarding-tooltip' - }); - - // Content - const content = createElement('div', { className: 'onboarding-content' }, [ - createElement('h3', {}, [step.title]), - createElement('p', {}, [step.content]) - ]); - this.tooltip.appendChild(content); - - // Footer - const footer = createElement('div', { className: 'onboarding-footer' }); - - // Step indicator - footer.appendChild(createElement('span', { - id: 'onboarding-step' - }, [`${this.currentStep + 1} / ${this.steps.length}`])); - - // Buttons - const buttons = createElement('div', { className: 'onboarding-buttons' }); - - if (this.currentStep > 0) { - buttons.appendChild(createElement('button', { - className: 'btn btn-ghost', - onclick: () => this.previous() - }, ['Zurück'])); - } - - buttons.appendChild(createElement('button', { - className: 'btn btn-ghost', - onclick: () => this.skip() - }, ['Überspringen'])); - - const isLast = this.currentStep === this.steps.length - 1; - buttons.appendChild(createElement('button', { - className: 'btn btn-primary', - onclick: () => this.next() - }, [isLast ? 'Fertig' : 'Weiter'])); - - footer.appendChild(buttons); - this.tooltip.appendChild(footer); - - document.body.appendChild(this.tooltip); - - // Position tooltip - this.positionTooltip(targetElement, step.position); - } - - positionTooltip(targetElement, position) { - const targetRect = targetElement.getBoundingClientRect(); - const tooltipRect = this.tooltip.getBoundingClientRect(); - - const padding = 16; - let top, left; - - switch (position) { - case 'top': - top = targetRect.top - tooltipRect.height - padding; - left = targetRect.left + (targetRect.width - tooltipRect.width) / 2; - break; - - case 'bottom': - top = targetRect.bottom + padding; - left = targetRect.left + (targetRect.width - tooltipRect.width) / 2; - break; - - case 'left': - top = targetRect.top + (targetRect.height - tooltipRect.height) / 2; - left = targetRect.left - tooltipRect.width - padding; - break; - - case 'right': - top = targetRect.top + (targetRect.height - tooltipRect.height) / 2; - left = targetRect.right + padding; - break; - - case 'bottom-left': - top = targetRect.bottom + padding; - left = targetRect.right - tooltipRect.width; - break; - - case 'bottom-right': - top = targetRect.bottom + padding; - left = targetRect.left; - break; - - default: - top = targetRect.bottom + padding; - left = targetRect.left; - } - - // Keep within viewport - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - - if (left < padding) left = padding; - if (left + tooltipRect.width > viewportWidth - padding) { - left = viewportWidth - tooltipRect.width - padding; - } - - if (top < padding) top = padding; - if (top + tooltipRect.height > viewportHeight - padding) { - top = viewportHeight - tooltipRect.height - padding; - } - - this.tooltip.style.top = `${top}px`; - this.tooltip.style.left = `${left}px`; - } - - // ===================== - // HELPERS - // ===================== - - shouldShowTour() { - return localStorage.getItem('tour_completed') !== 'true'; - } - - resetTour() { - localStorage.removeItem('tour_completed'); - } -} - -// Helper function for querying multiple elements -function $$(selector) { - return Array.from(document.querySelectorAll(selector)); -} - -// Create and export singleton -const tourManager = new TourManager(); - -export default tourManager; diff --git a/frontend/sw.js b/frontend/sw.js index e9c5f70..bd08881 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -4,7 +4,7 @@ * Offline support and caching */ -const CACHE_VERSION = '390'; +const CACHE_VERSION = '391'; const CACHE_NAME = 'taskmate-v' + CACHE_VERSION; const STATIC_CACHE_NAME = 'taskmate-static-v' + CACHE_VERSION; const DYNAMIC_CACHE_NAME = 'taskmate-dynamic-v' + CACHE_VERSION; @@ -33,7 +33,6 @@ const STATIC_ASSETS = [ '/js/list.js', '/js/shortcuts.js', '/js/undo.js', - '/js/tour.js', '/js/admin.js', '/js/proposals.js', '/js/notifications.js',