Sicherheits-Fixes, toter Code entfernt, Optimierungen
Sicherheit: - CSRF-Schutz auf allen API-Routes (admin, proposals, files, stats, export) - authenticateToken vor csrfProtection bei admin/proposals (CSRF-Bypass behoben) - CORS eingeschränkt auf taskmate.aegis-sight.de - JWT_SECRET und SESSION_TIMEOUT nicht mehr exportiert - Tote Auth-Funktionen entfernt (generateCsrfToken, generateToken Legacy) Toter Code entfernt: - 6 ungenutzte JS-Dateien (tour, dashboard, 4x contacts-*) - 2 ungenutzte CSS-Dateien (dashboard, contacts-extended) - backend/migrations/ Verzeichnis, knowledge.js.backup - Doppelter bcrypt require in database.js Optimierung: - Request-Logging filtert statische Assets (nur /api/ wird geloggt) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
@@ -1,6 +1,28 @@
|
|||||||
TASKMATE - CHANGELOG
|
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
|
19.03.2026 - v390 - Filter-Buttons in die View-Tabs-Zeile verschoben
|
||||||
================================================================================
|
================================================================================
|
||||||
|
|||||||
@@ -763,7 +763,6 @@ async function createDefaultUsers() {
|
|||||||
const adminExists = db.prepare('SELECT id, password_hash FROM users WHERE email = ? AND role = ?').get('admin@taskmate.local', 'admin');
|
const adminExists = db.prepare('SELECT id, password_hash FROM users WHERE email = ? AND role = ?').get('admin@taskmate.local', 'admin');
|
||||||
if (adminExists) {
|
if (adminExists) {
|
||||||
const correctAdminPassword = process.env.ADMIN_PASSWORD || 'admin123';
|
const correctAdminPassword = process.env.ADMIN_PASSWORD || 'admin123';
|
||||||
const bcrypt = require('bcryptjs');
|
|
||||||
|
|
||||||
// Prüfen ob das Passwort bereits korrekt ist
|
// Prüfen ob das Passwort bereits korrekt ist
|
||||||
const isCorrect = await bcrypt.compare(correctAdminPassword, adminExists.password_hash);
|
const isCorrect = await bcrypt.compare(correctAdminPassword, adminExists.password_hash);
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ if (!JWT_SECRET || JWT_SECRET.length < 32) {
|
|||||||
}
|
}
|
||||||
const ACCESS_TOKEN_EXPIRY = 60; // Minuten (kürzer für mehr Sicherheit)
|
const ACCESS_TOKEN_EXPIRY = 60; // Minuten (kürzer für mehr Sicherheit)
|
||||||
const REFRESH_TOKEN_EXPIRY = 7 * 24 * 60; // 7 Tage in Minuten
|
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)
|
* JWT Access-Token generieren (kurze Lebensdauer)
|
||||||
*/
|
*/
|
||||||
@@ -63,13 +61,6 @@ function generateRefreshToken(userId, ipAddress, userAgent) {
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Legacy generateToken für Rückwärtskompatibilität
|
|
||||||
*/
|
|
||||||
function generateToken(user) {
|
|
||||||
return generateAccessToken(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JWT-Token verifizieren
|
* JWT-Token verifizieren
|
||||||
*/
|
*/
|
||||||
@@ -120,7 +111,7 @@ function authenticateToken(req, res, next) {
|
|||||||
const refreshThreshold = 5 * 60 * 1000; // 5 Minuten vor Ablauf
|
const refreshThreshold = 5 * 60 * 1000; // 5 Minuten vor Ablauf
|
||||||
|
|
||||||
if (tokenExp - now < refreshThreshold) {
|
if (tokenExp - now < refreshThreshold) {
|
||||||
const newToken = generateToken({
|
const newToken = generateAccessToken({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
display_name: user.displayName,
|
display_name: user.displayName,
|
||||||
@@ -203,14 +194,6 @@ function authenticateSocket(socket, next) {
|
|||||||
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
|
* Refresh-Token validieren und neuen Access-Token generieren
|
||||||
*/
|
*/
|
||||||
@@ -272,7 +255,6 @@ function cleanupExpiredTokens() {
|
|||||||
setInterval(cleanupExpiredTokens, 6 * 60 * 60 * 1000);
|
setInterval(cleanupExpiredTokens, 6 * 60 * 60 * 1000);
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
generateToken,
|
|
||||||
generateAccessToken,
|
generateAccessToken,
|
||||||
generateRefreshToken,
|
generateRefreshToken,
|
||||||
refreshAccessToken,
|
refreshAccessToken,
|
||||||
@@ -280,10 +262,7 @@ module.exports = {
|
|||||||
verifyToken,
|
verifyToken,
|
||||||
authenticateToken,
|
authenticateToken,
|
||||||
authenticateSocket,
|
authenticateSocket,
|
||||||
generateCsrfToken,
|
|
||||||
requireAdmin,
|
requireAdmin,
|
||||||
requireRegularUser,
|
requireRegularUser,
|
||||||
checkPermission,
|
checkPermission
|
||||||
JWT_SECRET,
|
|
||||||
SESSION_TIMEOUT
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const express = require('express');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const { getDb } = require('../database');
|
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 { getTokenForUser } = require('../middleware/csrf');
|
||||||
const { validatePassword } = require('../middleware/validation');
|
const { validatePassword } = require('../middleware/validation');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
@@ -112,7 +112,7 @@ router.post('/login', async (req, res) => {
|
|||||||
logAttempt(user.id, true);
|
logAttempt(user.id, true);
|
||||||
|
|
||||||
// JWT Access-Token generieren (kurze Lebensdauer)
|
// JWT Access-Token generieren (kurze Lebensdauer)
|
||||||
const accessToken = generateToken(user);
|
const accessToken = generateAccessToken(user);
|
||||||
|
|
||||||
// Refresh-Token generieren (lange Lebensdauer)
|
// Refresh-Token generieren (lange Lebensdauer)
|
||||||
const refreshToken = generateRefreshToken(user.id, ip, userAgent);
|
const refreshToken = generateRefreshToken(user.id, ip, userAgent);
|
||||||
@@ -267,7 +267,7 @@ function legacyRefresh(req, res) {
|
|||||||
return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const newToken = generateToken(dbUser);
|
const newToken = generateAccessToken(dbUser);
|
||||||
const csrfToken = getTokenForUser(dbUser.id);
|
const csrfToken = getTokenForUser(dbUser.id);
|
||||||
|
|
||||||
res.json({ token: newToken, csrfToken });
|
res.json({ token: newToken, csrfToken });
|
||||||
|
|||||||
497
backend/routes/contacts-extended.js
Normale Datei
497
backend/routes/contacts-extended.js
Normale Datei
@@ -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;
|
||||||
@@ -82,7 +82,7 @@ app.use(helmet({
|
|||||||
|
|
||||||
// CORS
|
// CORS
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
origin: true,
|
origin: process.env.CORS_ORIGIN || 'https://taskmate.aegis-sight.de',
|
||||||
credentials: true
|
credentials: true
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -97,14 +97,15 @@ app.use(cookieParser());
|
|||||||
const { sanitizeMiddleware } = require('./middleware/validation');
|
const { sanitizeMiddleware } = require('./middleware/validation');
|
||||||
app.use(sanitizeMiddleware);
|
app.use(sanitizeMiddleware);
|
||||||
|
|
||||||
// Request Logging
|
// Request Logging (nur API-Requests, keine statischen Assets)
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
|
if (req.originalUrl.startsWith('/api/')) {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
res.on('finish', () => {
|
res.on('finish', () => {
|
||||||
const duration = Date.now() - start;
|
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`);
|
logger.info(`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -140,18 +141,18 @@ app.use('/api/tasks', authenticateToken, csrfProtection, taskRoutes);
|
|||||||
app.use('/api/subtasks', authenticateToken, csrfProtection, subtaskRoutes);
|
app.use('/api/subtasks', authenticateToken, csrfProtection, subtaskRoutes);
|
||||||
app.use('/api/comments', authenticateToken, csrfProtection, commentRoutes);
|
app.use('/api/comments', authenticateToken, csrfProtection, commentRoutes);
|
||||||
app.use('/api/labels', authenticateToken, csrfProtection, labelRoutes);
|
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/links', authenticateToken, csrfProtection, linkRoutes);
|
||||||
app.use('/api/templates', authenticateToken, csrfProtection, templateRoutes);
|
app.use('/api/templates', authenticateToken, csrfProtection, templateRoutes);
|
||||||
app.use('/api/stats', authenticateToken, statsRoutes);
|
app.use('/api/stats', authenticateToken, csrfProtection, statsRoutes);
|
||||||
app.use('/api/export', authenticateToken, exportRoutes);
|
app.use('/api/export', authenticateToken, csrfProtection, exportRoutes);
|
||||||
app.use('/api/import', authenticateToken, csrfProtection, importRoutes);
|
app.use('/api/import', authenticateToken, csrfProtection, importRoutes);
|
||||||
|
|
||||||
// Admin-Routes (eigene Auth-Middleware)
|
// Admin-Routes
|
||||||
app.use('/api/admin', csrfProtection, adminRoutes);
|
app.use('/api/admin', authenticateToken, csrfProtection, adminRoutes);
|
||||||
|
|
||||||
// Proposals-Routes (eigene Auth-Middleware)
|
// Proposals-Routes
|
||||||
app.use('/api/proposals', csrfProtection, proposalRoutes);
|
app.use('/api/proposals', authenticateToken, csrfProtection, proposalRoutes);
|
||||||
|
|
||||||
// Notifications-Routes
|
// Notifications-Routes
|
||||||
app.use('/api/notifications', authenticateToken, csrfProtection, notificationRoutes);
|
app.use('/api/notifications', authenticateToken, csrfProtection, notificationRoutes);
|
||||||
|
|||||||
765
frontend/css/contacts-modern.css
Normale Datei
765
frontend/css/contacts-modern.css
Normale Datei
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -38,7 +38,6 @@
|
|||||||
<link rel="stylesheet" href="css/knowledge.css">
|
<link rel="stylesheet" href="css/knowledge.css">
|
||||||
<link rel="stylesheet" href="css/reminders.css">
|
<link rel="stylesheet" href="css/reminders.css">
|
||||||
<link rel="stylesheet" href="css/contacts.css">
|
<link rel="stylesheet" href="css/contacts.css">
|
||||||
<link rel="stylesheet" href="css/contacts-modern.css">
|
|
||||||
<link rel="stylesheet" href="css/responsive.css">
|
<link rel="stylesheet" href="css/responsive.css">
|
||||||
<link rel="stylesheet" href="css/mobile.css">
|
<link rel="stylesheet" href="css/mobile.css">
|
||||||
<link rel="stylesheet" href="css/pwa.css">
|
<link rel="stylesheet" href="css/pwa.css">
|
||||||
|
|||||||
103
frontend/js/auth-fix.js
Normale Datei
103
frontend/js/auth-fix.js
Normale Datei
@@ -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
|
||||||
|
});
|
||||||
@@ -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;
|
|
||||||
274
frontend/js/mobile-swipe.js
Normale Datei
274
frontend/js/mobile-swipe.js
Normale Datei
@@ -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 = `
|
||||||
|
<div class="column-dots"></div>
|
||||||
|
<div class="column-name"></div>
|
||||||
|
`;
|
||||||
|
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) =>
|
||||||
|
`<span class="dot ${i === currentColumnIndex ? 'active' : ''}"></span>`
|
||||||
|
).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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
* Offline support and caching
|
* Offline support and caching
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE_VERSION = '390';
|
const CACHE_VERSION = '391';
|
||||||
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;
|
||||||
@@ -33,7 +33,6 @@ const STATIC_ASSETS = [
|
|||||||
'/js/list.js',
|
'/js/list.js',
|
||||||
'/js/shortcuts.js',
|
'/js/shortcuts.js',
|
||||||
'/js/undo.js',
|
'/js/undo.js',
|
||||||
'/js/tour.js',
|
|
||||||
'/js/admin.js',
|
'/js/admin.js',
|
||||||
'/js/proposals.js',
|
'/js/proposals.js',
|
||||||
'/js/notifications.js',
|
'/js/notifications.js',
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren