Initial commit
Dieser Commit ist enthalten in:
505
frontend/js/admin.js
Normale Datei
505
frontend/js/admin.js
Normale Datei
@ -0,0 +1,505 @@
|
||||
/**
|
||||
* TASKMATE - Admin Dashboard
|
||||
* ==========================
|
||||
* Benutzerverwaltung fuer Administratoren
|
||||
*/
|
||||
|
||||
import api from './api.js';
|
||||
import { $, $$ } from './utils.js';
|
||||
import authManager from './auth.js';
|
||||
import store from './store.js';
|
||||
|
||||
class AdminManager {
|
||||
constructor() {
|
||||
this.users = [];
|
||||
this.currentEditUser = null;
|
||||
this.uploadSettings = null;
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
async init() {
|
||||
console.log('[Admin] init() called, initialized:', this.initialized);
|
||||
|
||||
if (this.initialized) {
|
||||
await this.loadUsers();
|
||||
await this.loadUploadSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
// DOM Elements - erst bei init() laden
|
||||
this.adminScreen = $('#admin-screen');
|
||||
console.log('[Admin] adminScreen found:', !!this.adminScreen);
|
||||
this.usersList = $('#admin-users-list');
|
||||
this.logoutBtn = $('#admin-logout-btn');
|
||||
this.newUserBtn = $('#btn-new-user');
|
||||
|
||||
// Modal Elements
|
||||
this.userModal = $('#user-modal');
|
||||
this.userModalTitle = $('#user-modal-title');
|
||||
this.userForm = $('#user-form');
|
||||
this.editUserId = $('#edit-user-id');
|
||||
this.usernameInput = $('#user-username');
|
||||
this.displayNameInput = $('#user-displayname');
|
||||
this.emailInput = $('#user-email');
|
||||
this.passwordInput = $('#user-password');
|
||||
this.passwordHint = $('#password-hint');
|
||||
this.roleSelect = $('#user-role');
|
||||
this.permissionsGroup = $('#permissions-group');
|
||||
this.permGenehmigung = $('#perm-genehmigung');
|
||||
this.deleteUserBtn = $('#btn-delete-user');
|
||||
this.unlockUserBtn = $('#btn-unlock-user');
|
||||
|
||||
// Upload Settings Elements
|
||||
this.uploadMaxSizeInput = $('#upload-max-size');
|
||||
this.saveUploadSettingsBtn = $('#btn-save-upload-settings');
|
||||
this.uploadCategories = $$('.upload-category');
|
||||
|
||||
this.bindEvents();
|
||||
this.initialized = true;
|
||||
|
||||
await this.loadUsers();
|
||||
await this.loadUploadSettings();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Logout
|
||||
this.logoutBtn?.addEventListener('click', () => this.handleLogout());
|
||||
|
||||
// New User Button
|
||||
this.newUserBtn?.addEventListener('click', () => this.openNewUserModal());
|
||||
|
||||
// User Form Submit
|
||||
this.userForm?.addEventListener('submit', (e) => this.handleUserSubmit(e));
|
||||
|
||||
// Role Change - hide permissions for admin
|
||||
this.roleSelect?.addEventListener('change', () => this.togglePermissionsVisibility());
|
||||
|
||||
// Delete User Button
|
||||
this.deleteUserBtn?.addEventListener('click', () => this.handleDeleteUser());
|
||||
|
||||
// Unlock User Button
|
||||
this.unlockUserBtn?.addEventListener('click', () => this.handleUnlockUser());
|
||||
|
||||
// Modal close buttons
|
||||
this.userModal?.querySelectorAll('[data-close-modal]').forEach(btn => {
|
||||
btn.addEventListener('click', () => this.closeModal());
|
||||
});
|
||||
|
||||
// Upload Settings - Save Button
|
||||
this.saveUploadSettingsBtn?.addEventListener('click', () => this.saveUploadSettings());
|
||||
|
||||
// Upload Settings - Category Toggles
|
||||
this.uploadCategories?.forEach(category => {
|
||||
const checkbox = category.querySelector('input[type="checkbox"]');
|
||||
checkbox?.addEventListener('change', () => {
|
||||
this.toggleUploadCategory(category, checkbox.checked);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async loadUsers() {
|
||||
try {
|
||||
this.users = await api.getAdminUsers();
|
||||
this.renderUsers();
|
||||
} catch (error) {
|
||||
console.error('Error loading users:', error);
|
||||
this.showToast('Fehler beim Laden der Benutzer', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
renderUsers() {
|
||||
if (!this.usersList) return;
|
||||
|
||||
if (this.users.length === 0) {
|
||||
this.usersList.innerHTML = `
|
||||
<div class="admin-empty-state">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 2a5 5 0 0 1 5 5v2a5 5 0 0 1-10 0V7a5 5 0 0 1 5-5zm-7 18a7 7 0 0 1 14 0" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||
<p>Keine Benutzer vorhanden</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
this.usersList.innerHTML = this.users.map(user => this.renderUserCard(user)).join('');
|
||||
|
||||
// Bind edit buttons
|
||||
this.usersList.querySelectorAll('.admin-user-card').forEach(card => {
|
||||
const userId = parseInt(card.dataset.userId);
|
||||
const editBtn = card.querySelector('.btn-edit-user');
|
||||
editBtn?.addEventListener('click', () => this.openEditUserModal(userId));
|
||||
});
|
||||
}
|
||||
|
||||
renderUserCard(user) {
|
||||
const initial = (user.display_name || user.username).charAt(0).toUpperCase();
|
||||
const isLocked = user.locked_until && new Date(user.locked_until) > new Date();
|
||||
const permissions = user.permissions || [];
|
||||
|
||||
return `
|
||||
<div class="admin-user-card" data-user-id="${user.id}">
|
||||
<div class="admin-user-avatar" style="background-color: ${user.color || '#808080'}">
|
||||
${initial}
|
||||
</div>
|
||||
<div class="admin-user-info">
|
||||
<div class="admin-user-name">${this.escapeHtml(user.display_name)}</div>
|
||||
<div class="admin-user-username">@${this.escapeHtml(user.username)}${user.email ? ` · ${this.escapeHtml(user.email)}` : ''}</div>
|
||||
</div>
|
||||
<div class="admin-user-badges">
|
||||
<span class="admin-badge role-${user.role || 'user'}">
|
||||
${user.role === 'admin' ? 'Admin' : 'Benutzer'}
|
||||
</span>
|
||||
${permissions.map(p => `<span class="admin-badge permission">${this.escapeHtml(p)}</span>`).join('')}
|
||||
</div>
|
||||
<div class="admin-user-status">
|
||||
${isLocked ? '<span class="status-locked">Gesperrt</span>' : '<span class="status-active">Aktiv</span>'}
|
||||
</div>
|
||||
<div class="admin-user-actions">
|
||||
<button class="btn-edit-user" title="Bearbeiten">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" stroke="currentColor" stroke-width="2" fill="none"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
generateRandomPassword(length = 10) {
|
||||
const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
|
||||
const lower = 'abcdefghjkmnpqrstuvwxyz';
|
||||
const numbers = '23456789';
|
||||
const special = '!@#$%&*?';
|
||||
|
||||
// Mindestens ein Zeichen aus jeder Kategorie
|
||||
let password = '';
|
||||
password += upper.charAt(Math.floor(Math.random() * upper.length));
|
||||
password += lower.charAt(Math.floor(Math.random() * lower.length));
|
||||
password += numbers.charAt(Math.floor(Math.random() * numbers.length));
|
||||
password += special.charAt(Math.floor(Math.random() * special.length));
|
||||
|
||||
// Rest mit gemischten Zeichen auffüllen
|
||||
const allChars = upper + lower + numbers + special;
|
||||
for (let i = password.length; i < length; i++) {
|
||||
password += allChars.charAt(Math.floor(Math.random() * allChars.length));
|
||||
}
|
||||
|
||||
// Passwort mischen
|
||||
return password.split('').sort(() => Math.random() - 0.5).join('');
|
||||
}
|
||||
|
||||
openNewUserModal() {
|
||||
this.currentEditUser = null;
|
||||
this.userModalTitle.textContent = 'Neuer Benutzer';
|
||||
this.userForm.reset();
|
||||
this.editUserId.value = '';
|
||||
|
||||
// Passwort-Feld anzeigen (falls bei Bearbeitung versteckt)
|
||||
this.passwordInput.closest('.form-group').style.display = '';
|
||||
|
||||
// Zufälliges Passwort generieren und anzeigen (10 Zeichen mit Sonderzeichen)
|
||||
const randomPassword = this.generateRandomPassword(10);
|
||||
this.passwordInput.value = randomPassword;
|
||||
this.passwordInput.readOnly = true;
|
||||
this.passwordInput.type = 'text';
|
||||
this.passwordHint.textContent = '(automatisch generiert)';
|
||||
|
||||
this.usernameInput.disabled = false;
|
||||
this.emailInput.disabled = false;
|
||||
this.deleteUserBtn.classList.add('hidden');
|
||||
this.unlockUserBtn.classList.add('hidden');
|
||||
this.togglePermissionsVisibility();
|
||||
this.openModal();
|
||||
}
|
||||
|
||||
openEditUserModal(userId) {
|
||||
const user = this.users.find(u => u.id === userId);
|
||||
if (!user) return;
|
||||
|
||||
this.currentEditUser = user;
|
||||
this.userModalTitle.textContent = 'Benutzer bearbeiten';
|
||||
this.editUserId.value = user.id;
|
||||
this.usernameInput.value = user.username;
|
||||
this.usernameInput.disabled = true; // Username cannot be changed
|
||||
this.displayNameInput.value = user.display_name;
|
||||
this.emailInput.value = user.email || '';
|
||||
this.emailInput.disabled = false;
|
||||
|
||||
// Passwort-Feld bei Bearbeitung ausblenden
|
||||
this.passwordInput.closest('.form-group').style.display = 'none';
|
||||
|
||||
this.roleSelect.value = user.role || 'user';
|
||||
|
||||
// Set permissions
|
||||
const permissions = user.permissions || [];
|
||||
this.permGenehmigung.checked = permissions.includes('genehmigung');
|
||||
|
||||
// Show/hide delete button (cannot delete self or last admin)
|
||||
const canDelete = user.id !== authManager.getUser()?.id;
|
||||
this.deleteUserBtn.classList.toggle('hidden', !canDelete);
|
||||
|
||||
// Show unlock button if user is locked
|
||||
const isLocked = user.locked_until && new Date(user.locked_until) > new Date();
|
||||
this.unlockUserBtn.classList.toggle('hidden', !isLocked);
|
||||
|
||||
this.togglePermissionsVisibility();
|
||||
this.openModal();
|
||||
}
|
||||
|
||||
togglePermissionsVisibility() {
|
||||
const isAdmin = this.roleSelect.value === 'admin';
|
||||
this.permissionsGroup.style.display = isAdmin ? 'none' : 'block';
|
||||
}
|
||||
|
||||
async handleUserSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const userId = this.editUserId.value;
|
||||
const isEdit = !!userId;
|
||||
|
||||
const data = {
|
||||
displayName: this.displayNameInput.value.trim(),
|
||||
email: this.emailInput.value.trim(),
|
||||
role: this.roleSelect.value,
|
||||
permissions: this.roleSelect.value === 'admin' ? [] : this.getSelectedPermissions()
|
||||
};
|
||||
|
||||
if (!isEdit) {
|
||||
data.username = this.usernameInput.value.trim().toUpperCase();
|
||||
}
|
||||
|
||||
if (this.passwordInput.value) {
|
||||
data.password = this.passwordInput.value;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
await api.updateAdminUser(userId, data);
|
||||
this.showToast('Benutzer aktualisiert', 'success');
|
||||
} else {
|
||||
await api.createAdminUser(data);
|
||||
this.showToast('Benutzer erstellt', 'success');
|
||||
}
|
||||
|
||||
this.closeModal();
|
||||
await this.loadUsers();
|
||||
} catch (error) {
|
||||
this.showToast(error.message || 'Fehler beim Speichern', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async handleDeleteUser() {
|
||||
if (!this.currentEditUser) return;
|
||||
|
||||
const confirmDelete = confirm(`Benutzer "${this.currentEditUser.display_name}" wirklich löschen?`);
|
||||
if (!confirmDelete) return;
|
||||
|
||||
try {
|
||||
await api.deleteAdminUser(this.currentEditUser.id);
|
||||
this.showToast('Benutzer gelöscht', 'success');
|
||||
this.closeModal();
|
||||
await this.loadUsers();
|
||||
} catch (error) {
|
||||
this.showToast(error.message || 'Fehler beim Löschen', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async handleUnlockUser() {
|
||||
if (!this.currentEditUser) return;
|
||||
|
||||
try {
|
||||
await api.updateAdminUser(this.currentEditUser.id, { unlockAccount: true });
|
||||
this.showToast('Benutzer entsperrt', 'success');
|
||||
this.closeModal();
|
||||
await this.loadUsers();
|
||||
} catch (error) {
|
||||
this.showToast(error.message || 'Fehler beim Entsperren', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
getSelectedPermissions() {
|
||||
const permissions = [];
|
||||
if (this.permGenehmigung?.checked) {
|
||||
permissions.push('genehmigung');
|
||||
}
|
||||
return permissions;
|
||||
}
|
||||
|
||||
async handleLogout() {
|
||||
try {
|
||||
await authManager.logout();
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
openModal() {
|
||||
if (this.userModal) {
|
||||
this.userModal.classList.remove('hidden');
|
||||
this.userModal.classList.add('visible');
|
||||
}
|
||||
const overlay = $('#modal-overlay');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('hidden');
|
||||
overlay.classList.add('visible');
|
||||
}
|
||||
store.openModal('user-modal');
|
||||
}
|
||||
|
||||
closeModal() {
|
||||
if (this.userModal) {
|
||||
this.userModal.classList.remove('visible');
|
||||
this.userModal.classList.add('hidden');
|
||||
}
|
||||
// Only hide overlay if no other modals are open
|
||||
const openModals = store.get('openModals').filter(id => id !== 'user-modal');
|
||||
if (openModals.length === 0) {
|
||||
const overlay = $('#modal-overlay');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('visible');
|
||||
overlay.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
store.closeModal('user-modal');
|
||||
}
|
||||
|
||||
showToast(message, type = 'info') {
|
||||
window.dispatchEvent(new CustomEvent('toast:show', {
|
||||
detail: { message, type }
|
||||
}));
|
||||
}
|
||||
|
||||
escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// =====================
|
||||
// UPLOAD SETTINGS
|
||||
// =====================
|
||||
|
||||
async loadUploadSettings() {
|
||||
try {
|
||||
this.uploadSettings = await api.getUploadSettings();
|
||||
this.renderUploadSettings();
|
||||
} catch (error) {
|
||||
console.error('Error loading upload settings:', error);
|
||||
}
|
||||
}
|
||||
|
||||
renderUploadSettings() {
|
||||
if (!this.uploadSettings) return;
|
||||
|
||||
// Maximale Dateigröße setzen
|
||||
if (this.uploadMaxSizeInput) {
|
||||
this.uploadMaxSizeInput.value = this.uploadSettings.maxFileSizeMB || 15;
|
||||
}
|
||||
|
||||
// Kategorien setzen
|
||||
const categoryMap = {
|
||||
'images': 'upload-cat-images',
|
||||
'documents': 'upload-cat-documents',
|
||||
'office': 'upload-cat-office',
|
||||
'text': 'upload-cat-text',
|
||||
'archives': 'upload-cat-archives',
|
||||
'data': 'upload-cat-data'
|
||||
};
|
||||
|
||||
Object.entries(categoryMap).forEach(([category, checkboxId]) => {
|
||||
const checkbox = $(`#${checkboxId}`);
|
||||
const categoryEl = $(`.upload-category[data-category="${category}"]`);
|
||||
|
||||
if (checkbox && this.uploadSettings.allowedTypes?.[category]) {
|
||||
const isEnabled = this.uploadSettings.allowedTypes[category].enabled;
|
||||
checkbox.checked = isEnabled;
|
||||
this.toggleUploadCategory(categoryEl, isEnabled);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleUploadCategory(categoryEl, enabled) {
|
||||
if (!categoryEl) return;
|
||||
|
||||
if (enabled) {
|
||||
categoryEl.classList.remove('disabled');
|
||||
} else {
|
||||
categoryEl.classList.add('disabled');
|
||||
}
|
||||
}
|
||||
|
||||
async saveUploadSettings() {
|
||||
try {
|
||||
const maxFileSizeMB = parseInt(this.uploadMaxSizeInput?.value) || 15;
|
||||
|
||||
// Validierung
|
||||
if (maxFileSizeMB < 1 || maxFileSizeMB > 100) {
|
||||
this.showToast('Dateigröße muss zwischen 1 und 100 MB liegen', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Kategorien sammeln
|
||||
const allowedTypes = {
|
||||
images: {
|
||||
enabled: $('#upload-cat-images')?.checked ?? true,
|
||||
types: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml']
|
||||
},
|
||||
documents: {
|
||||
enabled: $('#upload-cat-documents')?.checked ?? true,
|
||||
types: ['application/pdf']
|
||||
},
|
||||
office: {
|
||||
enabled: $('#upload-cat-office')?.checked ?? true,
|
||||
types: [
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
||||
]
|
||||
},
|
||||
text: {
|
||||
enabled: $('#upload-cat-text')?.checked ?? true,
|
||||
types: ['text/plain', 'text/csv', 'text/markdown']
|
||||
},
|
||||
archives: {
|
||||
enabled: $('#upload-cat-archives')?.checked ?? true,
|
||||
types: ['application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed']
|
||||
},
|
||||
data: {
|
||||
enabled: $('#upload-cat-data')?.checked ?? true,
|
||||
types: ['application/json']
|
||||
}
|
||||
};
|
||||
|
||||
// Prüfen ob mindestens eine Kategorie aktiviert ist
|
||||
const hasEnabledCategory = Object.values(allowedTypes).some(cat => cat.enabled);
|
||||
if (!hasEnabledCategory) {
|
||||
this.showToast('Mindestens eine Dateikategorie muss aktiviert sein', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
await api.updateUploadSettings({ maxFileSizeMB, allowedTypes });
|
||||
|
||||
this.uploadSettings = { maxFileSizeMB, allowedTypes };
|
||||
this.showToast('Upload-Einstellungen gespeichert', 'success');
|
||||
} catch (error) {
|
||||
console.error('Error saving upload settings:', error);
|
||||
this.showToast(error.message || 'Fehler beim Speichern', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
show() {
|
||||
this.adminScreen?.classList.add('active');
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.adminScreen?.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const adminManager = new AdminManager();
|
||||
|
||||
export { adminManager };
|
||||
export default adminManager;
|
||||
862
frontend/js/api.js
Normale Datei
862
frontend/js/api.js
Normale Datei
@ -0,0 +1,862 @@
|
||||
/**
|
||||
* TASKMATE - API Client
|
||||
* =====================
|
||||
*/
|
||||
|
||||
class ApiClient {
|
||||
constructor() {
|
||||
this.baseUrl = '/api';
|
||||
this.token = null;
|
||||
this.csrfToken = null;
|
||||
this.refreshingToken = false;
|
||||
this.requestQueue = [];
|
||||
}
|
||||
|
||||
// Token Management
|
||||
setToken(token) {
|
||||
this.token = token;
|
||||
if (token) {
|
||||
localStorage.setItem('auth_token', token);
|
||||
} else {
|
||||
localStorage.removeItem('auth_token');
|
||||
}
|
||||
}
|
||||
|
||||
getToken() {
|
||||
if (!this.token) {
|
||||
this.token = localStorage.getItem('auth_token');
|
||||
}
|
||||
return this.token;
|
||||
}
|
||||
|
||||
setCsrfToken(token) {
|
||||
this.csrfToken = token;
|
||||
if (token) {
|
||||
sessionStorage.setItem('csrf_token', token);
|
||||
} else {
|
||||
sessionStorage.removeItem('csrf_token');
|
||||
}
|
||||
}
|
||||
|
||||
getCsrfToken() {
|
||||
if (!this.csrfToken) {
|
||||
this.csrfToken = sessionStorage.getItem('csrf_token');
|
||||
}
|
||||
return this.csrfToken;
|
||||
}
|
||||
|
||||
// Base Request Method
|
||||
async request(endpoint, options = {}) {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
};
|
||||
|
||||
// Add auth token
|
||||
const token = this.getToken();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Add CSRF token for mutating requests
|
||||
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(options.method)) {
|
||||
const csrfToken = this.getCsrfToken();
|
||||
if (csrfToken) {
|
||||
headers['X-CSRF-Token'] = csrfToken;
|
||||
}
|
||||
}
|
||||
|
||||
const config = {
|
||||
method: options.method || 'GET',
|
||||
headers,
|
||||
credentials: 'same-origin'
|
||||
};
|
||||
|
||||
if (options.body && config.method !== 'GET') {
|
||||
config.body = JSON.stringify(options.body);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, config);
|
||||
|
||||
// Update CSRF token if provided
|
||||
const newCsrfToken = response.headers.get('X-CSRF-Token');
|
||||
if (newCsrfToken) {
|
||||
this.setCsrfToken(newCsrfToken);
|
||||
}
|
||||
|
||||
// Handle 401 Unauthorized
|
||||
if (response.status === 401) {
|
||||
this.setToken(null);
|
||||
window.dispatchEvent(new CustomEvent('auth:logout'));
|
||||
throw new ApiError('Sitzung abgelaufen', 401);
|
||||
}
|
||||
|
||||
// Handle CSRF errors - update token and retry once
|
||||
if (response.status === 403) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
if (errorData.code === 'CSRF_ERROR' && errorData.csrfToken) {
|
||||
// Store the new CSRF token
|
||||
this.setCsrfToken(errorData.csrfToken);
|
||||
|
||||
// Retry the request once with the new token
|
||||
if (!options._csrfRetried) {
|
||||
return this.request(endpoint, { ...options, _csrfRetried: true });
|
||||
}
|
||||
}
|
||||
throw new ApiError(
|
||||
errorData.error || 'Zugriff verweigert',
|
||||
response.status,
|
||||
errorData
|
||||
);
|
||||
}
|
||||
|
||||
// Handle other errors
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new ApiError(
|
||||
errorData.error || `HTTP Error ${response.status}`,
|
||||
response.status,
|
||||
errorData
|
||||
);
|
||||
}
|
||||
|
||||
// Parse response
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Network error
|
||||
if (!navigator.onLine) {
|
||||
throw new ApiError('Keine Internetverbindung', 0, { offline: true });
|
||||
}
|
||||
|
||||
throw new ApiError('Netzwerkfehler: ' + error.message, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience Methods
|
||||
get(endpoint, options = {}) {
|
||||
return this.request(endpoint, { ...options, method: 'GET' });
|
||||
}
|
||||
|
||||
post(endpoint, body, options = {}) {
|
||||
return this.request(endpoint, { ...options, method: 'POST', body });
|
||||
}
|
||||
|
||||
put(endpoint, body, options = {}) {
|
||||
return this.request(endpoint, { ...options, method: 'PUT', body });
|
||||
}
|
||||
|
||||
patch(endpoint, body, options = {}) {
|
||||
return this.request(endpoint, { ...options, method: 'PATCH', body });
|
||||
}
|
||||
|
||||
delete(endpoint, options = {}) {
|
||||
return this.request(endpoint, { ...options, method: 'DELETE' });
|
||||
}
|
||||
|
||||
// File Upload
|
||||
async uploadFile(endpoint, file, onProgress, _retried = false) {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
const formData = new FormData();
|
||||
formData.append('files', file);
|
||||
|
||||
const token = this.getToken();
|
||||
const csrfToken = this.getCsrfToken();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.open('POST', url);
|
||||
|
||||
if (token) {
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
if (csrfToken) {
|
||||
xhr.setRequestHeader('X-CSRF-Token', csrfToken);
|
||||
}
|
||||
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable && onProgress) {
|
||||
const percentage = Math.round((e.loaded / e.total) * 100);
|
||||
onProgress(percentage);
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', async () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
resolve(JSON.parse(xhr.responseText));
|
||||
} catch {
|
||||
resolve(xhr.responseText);
|
||||
}
|
||||
} else if (xhr.status === 403 && !_retried) {
|
||||
// Handle CSRF error - get new token and retry
|
||||
try {
|
||||
const errorData = JSON.parse(xhr.responseText);
|
||||
if (errorData.code === 'CSRF_ERROR' && errorData.csrfToken) {
|
||||
this.setCsrfToken(errorData.csrfToken);
|
||||
// Retry with new token
|
||||
try {
|
||||
const result = await this.uploadFile(endpoint, file, onProgress, true);
|
||||
resolve(result);
|
||||
} catch (retryError) {
|
||||
reject(retryError);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
reject(new ApiError('Upload fehlgeschlagen', xhr.status));
|
||||
} else {
|
||||
try {
|
||||
const error = JSON.parse(xhr.responseText);
|
||||
reject(new ApiError(error.error || 'Upload fehlgeschlagen', xhr.status));
|
||||
} catch {
|
||||
reject(new ApiError('Upload fehlgeschlagen', xhr.status));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
reject(new ApiError('Netzwerkfehler beim Upload', 0));
|
||||
});
|
||||
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
// Download File
|
||||
async downloadFile(endpoint, filename) {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
const token = this.getToken();
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError('Download fehlgeschlagen', response.status);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const downloadUrl = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = downloadUrl;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(downloadUrl);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// AUTH ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async login(username, password) {
|
||||
const response = await this.post('/auth/login', { username, password });
|
||||
this.setToken(response.token);
|
||||
// Store CSRF token from login response
|
||||
if (response.csrfToken) {
|
||||
this.setCsrfToken(response.csrfToken);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
await this.post('/auth/logout', {});
|
||||
} finally {
|
||||
this.setToken(null);
|
||||
this.setCsrfToken(null);
|
||||
}
|
||||
}
|
||||
|
||||
async changePassword(currentPassword, newPassword) {
|
||||
return this.post('/auth/password', { currentPassword, newPassword });
|
||||
}
|
||||
|
||||
async updateUserColor(color) {
|
||||
return this.put('/auth/color', { color });
|
||||
}
|
||||
|
||||
async getUsers() {
|
||||
return this.get('/auth/users');
|
||||
}
|
||||
|
||||
// =====================
|
||||
// PROJECT ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async getProjects() {
|
||||
return this.get('/projects');
|
||||
}
|
||||
|
||||
async getProject(id) {
|
||||
return this.get(`/projects/${id}`);
|
||||
}
|
||||
|
||||
async createProject(data) {
|
||||
return this.post('/projects', data);
|
||||
}
|
||||
|
||||
async updateProject(id, data) {
|
||||
return this.put(`/projects/${id}`, data);
|
||||
}
|
||||
|
||||
async deleteProject(id, force = true) {
|
||||
return this.delete(`/projects/${id}?force=${force}`);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// COLUMN ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async getColumns(projectId) {
|
||||
return this.get(`/columns/${projectId}`);
|
||||
}
|
||||
|
||||
async createColumn(projectId, data) {
|
||||
return this.post('/columns', { ...data, projectId });
|
||||
}
|
||||
|
||||
async updateColumn(projectId, columnId, data) {
|
||||
return this.put(`/columns/${columnId}`, data);
|
||||
}
|
||||
|
||||
async deleteColumn(projectId, columnId) {
|
||||
return this.delete(`/columns/${columnId}`);
|
||||
}
|
||||
|
||||
async reorderColumns(projectId, columnId, newPosition) {
|
||||
return this.put(`/columns/${columnId}/position`, { newPosition });
|
||||
}
|
||||
|
||||
// =====================
|
||||
// TASK ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async getTasks(projectId, params = {}) {
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
const endpoint = `/tasks/project/${projectId}${queryString ? '?' + queryString : ''}`;
|
||||
return this.get(endpoint);
|
||||
}
|
||||
|
||||
async getTask(projectId, taskId) {
|
||||
return this.get(`/tasks/${taskId}`);
|
||||
}
|
||||
|
||||
async createTask(projectId, data) {
|
||||
return this.post('/tasks', { ...data, projectId });
|
||||
}
|
||||
|
||||
async updateTask(projectId, taskId, data) {
|
||||
return this.put(`/tasks/${taskId}`, data);
|
||||
}
|
||||
|
||||
async deleteTask(projectId, taskId) {
|
||||
return this.delete(`/tasks/${taskId}`);
|
||||
}
|
||||
|
||||
async moveTask(projectId, taskId, columnId, position) {
|
||||
return this.put(`/tasks/${taskId}/move`, { columnId, position });
|
||||
}
|
||||
|
||||
async duplicateTask(projectId, taskId) {
|
||||
return this.post(`/tasks/${taskId}/duplicate`, {});
|
||||
}
|
||||
|
||||
async archiveTask(projectId, taskId) {
|
||||
return this.put(`/tasks/${taskId}/archive`, { archived: true });
|
||||
}
|
||||
|
||||
async restoreTask(projectId, taskId) {
|
||||
return this.put(`/tasks/${taskId}/archive`, { archived: false });
|
||||
}
|
||||
|
||||
async getTaskHistory(projectId, taskId) {
|
||||
return this.get(`/tasks/${taskId}/history`);
|
||||
}
|
||||
|
||||
async searchTasks(projectId, query) {
|
||||
return this.get(`/tasks/search?projectId=${projectId}&q=${encodeURIComponent(query)}`);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// SUBTASK ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async getSubtasks(projectId, taskId) {
|
||||
return this.get(`/subtasks/${taskId}`);
|
||||
}
|
||||
|
||||
async createSubtask(projectId, taskId, data) {
|
||||
return this.post('/subtasks', { ...data, taskId });
|
||||
}
|
||||
|
||||
async updateSubtask(projectId, taskId, subtaskId, data) {
|
||||
return this.put(`/subtasks/${subtaskId}`, data);
|
||||
}
|
||||
|
||||
async deleteSubtask(projectId, taskId, subtaskId) {
|
||||
return this.delete(`/subtasks/${subtaskId}`);
|
||||
}
|
||||
|
||||
async reorderSubtasks(projectId, taskId, subtaskId, newPosition) {
|
||||
return this.put(`/subtasks/${subtaskId}/position`, { newPosition });
|
||||
}
|
||||
|
||||
// =====================
|
||||
// COMMENT ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async getComments(projectId, taskId) {
|
||||
return this.get(`/comments/${taskId}`);
|
||||
}
|
||||
|
||||
async createComment(projectId, taskId, data) {
|
||||
return this.post('/comments', { ...data, taskId });
|
||||
}
|
||||
|
||||
async updateComment(projectId, taskId, commentId, data) {
|
||||
return this.put(`/comments/${commentId}`, data);
|
||||
}
|
||||
|
||||
async deleteComment(projectId, taskId, commentId) {
|
||||
return this.delete(`/comments/${commentId}`);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// LABEL ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async getLabels(projectId) {
|
||||
return this.get(`/labels/${projectId}`);
|
||||
}
|
||||
|
||||
async createLabel(projectId, data) {
|
||||
return this.post('/labels', { ...data, projectId });
|
||||
}
|
||||
|
||||
async updateLabel(projectId, labelId, data) {
|
||||
return this.put(`/labels/${labelId}`, data);
|
||||
}
|
||||
|
||||
async deleteLabel(projectId, labelId) {
|
||||
return this.delete(`/labels/${labelId}`);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// FILE ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async getFiles(projectId, taskId) {
|
||||
return this.get(`/files/${taskId}`);
|
||||
}
|
||||
|
||||
async uploadTaskFile(projectId, taskId, file, onProgress) {
|
||||
return this.uploadFile(`/files/${taskId}`, file, onProgress);
|
||||
}
|
||||
|
||||
async downloadTaskFile(projectId, taskId, fileId, filename) {
|
||||
return this.downloadFile(`/files/download/${fileId}`, filename);
|
||||
}
|
||||
|
||||
async deleteFile(projectId, taskId, fileId) {
|
||||
return this.delete(`/files/${fileId}`);
|
||||
}
|
||||
|
||||
getFilePreviewUrl(projectId, taskId, fileId) {
|
||||
const token = this.getToken();
|
||||
const url = `${this.baseUrl}/files/preview/${fileId}`;
|
||||
// Add token as query parameter for img src authentication
|
||||
return token ? `${url}?token=${encodeURIComponent(token)}` : url;
|
||||
}
|
||||
|
||||
// =====================
|
||||
// LINK ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async getLinks(projectId, taskId) {
|
||||
return this.get(`/links/${taskId}`);
|
||||
}
|
||||
|
||||
async createLink(projectId, taskId, data) {
|
||||
return this.post('/links', { ...data, taskId });
|
||||
}
|
||||
|
||||
async updateLink(projectId, taskId, linkId, data) {
|
||||
return this.put(`/links/${linkId}`, data);
|
||||
}
|
||||
|
||||
async deleteLink(projectId, taskId, linkId) {
|
||||
return this.delete(`/links/${linkId}`);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// TEMPLATE ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async getTemplates() {
|
||||
return this.get('/templates');
|
||||
}
|
||||
|
||||
async getTemplate(id) {
|
||||
return this.get(`/templates/${id}`);
|
||||
}
|
||||
|
||||
async createTemplate(data) {
|
||||
return this.post('/templates', data);
|
||||
}
|
||||
|
||||
async updateTemplate(id, data) {
|
||||
return this.put(`/templates/${id}`, data);
|
||||
}
|
||||
|
||||
async deleteTemplate(id) {
|
||||
return this.delete(`/templates/${id}`);
|
||||
}
|
||||
|
||||
async createTaskFromTemplate(projectId, templateId, columnId) {
|
||||
return this.post(`/templates/${templateId}/apply`, { projectId, columnId });
|
||||
}
|
||||
|
||||
// =====================
|
||||
// STATS ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async getStats(projectId) {
|
||||
const endpoint = projectId ? `/stats/dashboard?projectId=${projectId}` : '/stats/dashboard';
|
||||
return this.get(endpoint);
|
||||
}
|
||||
|
||||
async getCompletionStats(projectId, weeks = 8) {
|
||||
const endpoint = projectId
|
||||
? `/stats/completed-per-week?projectId=${projectId}&weeks=${weeks}`
|
||||
: `/stats/completed-per-week?weeks=${weeks}`;
|
||||
return this.get(endpoint);
|
||||
}
|
||||
|
||||
async getTimeStats(projectId) {
|
||||
return this.get('/stats/time-per-project');
|
||||
}
|
||||
|
||||
async getOverdueTasks(projectId) {
|
||||
// Placeholder - overdue data comes from dashboard stats
|
||||
return [];
|
||||
}
|
||||
|
||||
async getDueTodayTasks(projectId) {
|
||||
// Placeholder - due today data comes from dashboard stats
|
||||
return [];
|
||||
}
|
||||
|
||||
// =====================
|
||||
// EXPORT/IMPORT ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async exportProject(projectId, format = 'json') {
|
||||
const response = await this.get(`/export/project/${projectId}?format=${format}`);
|
||||
|
||||
if (format === 'csv') {
|
||||
// For CSV, trigger download
|
||||
const blob = new Blob([response], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `project_${projectId}_export.csv`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async exportAll(format = 'json') {
|
||||
return this.get(`/export/all?format=${format}`);
|
||||
}
|
||||
|
||||
async importProject(data) {
|
||||
return this.post('/import', data);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// HEALTH ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async healthCheck() {
|
||||
return this.get('/health');
|
||||
}
|
||||
|
||||
async getSystemInfo() {
|
||||
return this.get('/health/info');
|
||||
}
|
||||
|
||||
// =====================
|
||||
// ADMIN ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async getAdminUsers() {
|
||||
return this.get('/admin/users');
|
||||
}
|
||||
|
||||
async createAdminUser(data) {
|
||||
return this.post('/admin/users', data);
|
||||
}
|
||||
|
||||
async updateAdminUser(userId, data) {
|
||||
return this.put(`/admin/users/${userId}`, data);
|
||||
}
|
||||
|
||||
async deleteAdminUser(userId) {
|
||||
return this.delete(`/admin/users/${userId}`);
|
||||
}
|
||||
|
||||
async getUploadSettings() {
|
||||
return this.get('/admin/upload-settings');
|
||||
}
|
||||
|
||||
async updateUploadSettings(settings) {
|
||||
return this.put('/admin/upload-settings', settings);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// PROPOSALS (GENEHMIGUNGEN) ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async getProposals(sort = 'date', archived = false, projectId = null) {
|
||||
let url = `/proposals?sort=${sort}&archived=${archived ? '1' : '0'}`;
|
||||
if (projectId) {
|
||||
url += `&projectId=${projectId}`;
|
||||
}
|
||||
return this.get(url);
|
||||
}
|
||||
|
||||
async createProposal(data) {
|
||||
return this.post('/proposals', data);
|
||||
}
|
||||
|
||||
async approveProposal(proposalId, approved) {
|
||||
return this.put(`/proposals/${proposalId}/approve`, { approved });
|
||||
}
|
||||
|
||||
async archiveProposal(proposalId, archived) {
|
||||
return this.put(`/proposals/${proposalId}/archive`, { archived });
|
||||
}
|
||||
|
||||
async deleteProposal(proposalId) {
|
||||
return this.delete(`/proposals/${proposalId}`);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// TASKS FOR PROPOSALS
|
||||
// =====================
|
||||
|
||||
async getAllTasks() {
|
||||
return this.get('/tasks/all');
|
||||
}
|
||||
|
||||
// =====================
|
||||
// NOTIFICATION ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async getNotifications() {
|
||||
return this.get('/notifications');
|
||||
}
|
||||
|
||||
async getNotificationCount() {
|
||||
return this.get('/notifications/count');
|
||||
}
|
||||
|
||||
async markNotificationRead(id) {
|
||||
return this.put(`/notifications/${id}/read`, {});
|
||||
}
|
||||
|
||||
async markAllNotificationsRead() {
|
||||
return this.put('/notifications/read-all', {});
|
||||
}
|
||||
|
||||
async deleteNotification(id) {
|
||||
return this.delete(`/notifications/${id}`);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// GITEA ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async testGiteaConnection() {
|
||||
return this.get('/gitea/test');
|
||||
}
|
||||
|
||||
async getGiteaRepositories(page = 1, limit = 50) {
|
||||
return this.get(`/gitea/repositories?page=${page}&limit=${limit}`);
|
||||
}
|
||||
|
||||
async createGiteaRepository(data) {
|
||||
return this.post('/gitea/repositories', data);
|
||||
}
|
||||
|
||||
async getGiteaRepository(owner, repo) {
|
||||
return this.get(`/gitea/repositories/${owner}/${repo}`);
|
||||
}
|
||||
|
||||
async deleteGiteaRepository(owner, repo) {
|
||||
return this.delete(`/gitea/repositories/${owner}/${repo}`);
|
||||
}
|
||||
|
||||
async getGiteaBranches(owner, repo) {
|
||||
return this.get(`/gitea/repositories/${owner}/${repo}/branches`);
|
||||
}
|
||||
|
||||
async getGiteaCommits(owner, repo, options = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (options.page) params.append('page', options.page);
|
||||
if (options.limit) params.append('limit', options.limit);
|
||||
if (options.branch) params.append('branch', options.branch);
|
||||
const queryString = params.toString();
|
||||
return this.get(`/gitea/repositories/${owner}/${repo}/commits${queryString ? '?' + queryString : ''}`);
|
||||
}
|
||||
|
||||
async getGiteaUser() {
|
||||
return this.get('/gitea/user');
|
||||
}
|
||||
|
||||
// =====================
|
||||
// APPLICATIONS (Projekt-Repository-Verknüpfung) ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async getProjectApplication(projectId) {
|
||||
return this.get(`/applications/${projectId}`);
|
||||
}
|
||||
|
||||
async saveProjectApplication(data) {
|
||||
return this.post('/applications', data);
|
||||
}
|
||||
|
||||
async deleteProjectApplication(projectId) {
|
||||
return this.delete(`/applications/${projectId}`);
|
||||
}
|
||||
|
||||
async getUserBasePath() {
|
||||
return this.get('/applications/user/base-path');
|
||||
}
|
||||
|
||||
async setUserBasePath(basePath) {
|
||||
return this.put('/applications/user/base-path', { basePath });
|
||||
}
|
||||
|
||||
async syncProjectApplication(projectId) {
|
||||
return this.post(`/applications/${projectId}/sync`, {});
|
||||
}
|
||||
|
||||
// =====================
|
||||
// GIT OPERATIONS ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async cloneRepository(data) {
|
||||
return this.post('/git/clone', data);
|
||||
}
|
||||
|
||||
async getGitStatus(projectId) {
|
||||
return this.get(`/git/status/${projectId}`);
|
||||
}
|
||||
|
||||
async gitPull(projectId, branch = null) {
|
||||
return this.post(`/git/pull/${projectId}`, { branch });
|
||||
}
|
||||
|
||||
async gitPush(projectId, branch = null) {
|
||||
return this.post(`/git/push/${projectId}`, { branch });
|
||||
}
|
||||
|
||||
async gitCommit(projectId, message, stageAll = true) {
|
||||
return this.post(`/git/commit/${projectId}`, { message, stageAll });
|
||||
}
|
||||
|
||||
async getGitCommits(projectId, limit = 20) {
|
||||
return this.get(`/git/commits/${projectId}?limit=${limit}`);
|
||||
}
|
||||
|
||||
async getGitBranches(projectId) {
|
||||
return this.get(`/git/branches/${projectId}`);
|
||||
}
|
||||
|
||||
async gitCheckout(projectId, branch) {
|
||||
return this.post(`/git/checkout/${projectId}`, { branch });
|
||||
}
|
||||
|
||||
async gitFetch(projectId) {
|
||||
return this.post(`/git/fetch/${projectId}`, {});
|
||||
}
|
||||
|
||||
async gitStage(projectId) {
|
||||
return this.post(`/git/stage/${projectId}`, {});
|
||||
}
|
||||
|
||||
async getGitRemote(projectId) {
|
||||
return this.get(`/git/remote/${projectId}`);
|
||||
}
|
||||
|
||||
async validatePath(path) {
|
||||
return this.post('/git/validate-path', { path });
|
||||
}
|
||||
|
||||
async prepareRepository(projectId, repoUrl, branch = 'main') {
|
||||
return this.post(`/git/prepare/${projectId}`, { repoUrl, branch });
|
||||
}
|
||||
|
||||
async setGitRemote(projectId, repoUrl) {
|
||||
return this.post(`/git/set-remote/${projectId}`, { repoUrl });
|
||||
}
|
||||
|
||||
async gitInitPush(projectId, branch = 'main') {
|
||||
return this.post(`/git/init-push/${projectId}`, { branch });
|
||||
}
|
||||
}
|
||||
|
||||
// Custom API Error Class
|
||||
class ApiError extends Error {
|
||||
constructor(message, status, data = {}) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
get isOffline() {
|
||||
return this.data.offline === true;
|
||||
}
|
||||
|
||||
get isUnauthorized() {
|
||||
return this.status === 401;
|
||||
}
|
||||
|
||||
get isNotFound() {
|
||||
return this.status === 404;
|
||||
}
|
||||
|
||||
get isValidationError() {
|
||||
return this.status === 400;
|
||||
}
|
||||
|
||||
get isServerError() {
|
||||
return this.status >= 500;
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const api = new ApiClient();
|
||||
|
||||
export { api, ApiError };
|
||||
export default api;
|
||||
1474
frontend/js/app.js
Normale Datei
1474
frontend/js/app.js
Normale Datei
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
546
frontend/js/auth.js
Normale Datei
546
frontend/js/auth.js
Normale Datei
@ -0,0 +1,546 @@
|
||||
/**
|
||||
* TASKMATE - Authentication Module
|
||||
* ================================
|
||||
*/
|
||||
|
||||
import api from './api.js';
|
||||
import { $, $$ } from './utils.js';
|
||||
|
||||
class AuthManager {
|
||||
constructor() {
|
||||
this.user = null;
|
||||
this.isAuthenticated = false;
|
||||
this.loginAttempts = 0;
|
||||
this.maxLoginAttempts = 5;
|
||||
this.lockoutDuration = 5 * 60 * 1000; // 5 minutes
|
||||
this.lockoutUntil = null;
|
||||
}
|
||||
|
||||
// Initialize authentication state
|
||||
async init() {
|
||||
const token = api.getToken();
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
// Verify token by making a request
|
||||
const users = await api.getUsers();
|
||||
this.isAuthenticated = true;
|
||||
|
||||
// Get current user from stored data
|
||||
const storedUser = localStorage.getItem('current_user');
|
||||
if (storedUser) {
|
||||
this.user = JSON.parse(storedUser);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Token invalid
|
||||
this.logout();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Login
|
||||
async login(username, password) {
|
||||
// Check lockout
|
||||
if (this.isLockedOut()) {
|
||||
const remainingTime = Math.ceil((this.lockoutUntil - Date.now()) / 1000);
|
||||
throw new Error(`Zu viele Fehlversuche. Bitte warten Sie ${remainingTime} Sekunden.`);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.login(username, password);
|
||||
|
||||
this.user = response.user;
|
||||
this.isAuthenticated = true;
|
||||
this.loginAttempts = 0;
|
||||
|
||||
// Store user data
|
||||
localStorage.setItem('current_user', JSON.stringify(response.user));
|
||||
|
||||
// Dispatch login event
|
||||
window.dispatchEvent(new CustomEvent('auth:login', {
|
||||
detail: { user: this.user }
|
||||
}));
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
this.loginAttempts++;
|
||||
|
||||
if (this.loginAttempts >= this.maxLoginAttempts) {
|
||||
this.lockoutUntil = Date.now() + this.lockoutDuration;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Logout
|
||||
async logout() {
|
||||
try {
|
||||
if (this.isAuthenticated) {
|
||||
await api.logout();
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore logout errors
|
||||
} finally {
|
||||
this.user = null;
|
||||
this.isAuthenticated = false;
|
||||
|
||||
// Clear stored data
|
||||
localStorage.removeItem('current_user');
|
||||
localStorage.removeItem('auth_token');
|
||||
|
||||
// Dispatch logout event
|
||||
window.dispatchEvent(new CustomEvent('auth:logout'));
|
||||
}
|
||||
}
|
||||
|
||||
// Change Password
|
||||
async changePassword(currentPassword, newPassword) {
|
||||
return api.changePassword(currentPassword, newPassword);
|
||||
}
|
||||
|
||||
// Get Current User
|
||||
getUser() {
|
||||
return this.user;
|
||||
}
|
||||
|
||||
// Check if logged in
|
||||
isLoggedIn() {
|
||||
return this.isAuthenticated && this.user !== null;
|
||||
}
|
||||
|
||||
// Check lockout status
|
||||
isLockedOut() {
|
||||
if (!this.lockoutUntil) return false;
|
||||
|
||||
if (Date.now() >= this.lockoutUntil) {
|
||||
this.lockoutUntil = null;
|
||||
this.loginAttempts = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get user initials
|
||||
getUserInitials() {
|
||||
if (!this.user) return '?';
|
||||
|
||||
return this.user.username
|
||||
.split(' ')
|
||||
.map(part => part.charAt(0).toUpperCase())
|
||||
.slice(0, 2)
|
||||
.join('');
|
||||
}
|
||||
|
||||
// Get user color
|
||||
getUserColor() {
|
||||
if (!this.user) return '#888888';
|
||||
return this.user.color || '#00D4FF';
|
||||
}
|
||||
|
||||
// Update user color
|
||||
updateUserColor(color) {
|
||||
if (this.user) {
|
||||
this.user.color = color;
|
||||
// Also update localStorage so color persists after refresh
|
||||
localStorage.setItem('current_user', JSON.stringify(this.user));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
isAdmin() {
|
||||
return this.user?.role === 'admin';
|
||||
}
|
||||
|
||||
// Check if user has a specific permission
|
||||
hasPermission(permission) {
|
||||
if (!this.user) return false;
|
||||
const permissions = this.user.permissions || [];
|
||||
return permissions.includes(permission);
|
||||
}
|
||||
|
||||
// Get user role
|
||||
getRole() {
|
||||
return this.user?.role || 'user';
|
||||
}
|
||||
|
||||
// Get user permissions
|
||||
getPermissions() {
|
||||
return this.user?.permissions || [];
|
||||
}
|
||||
}
|
||||
|
||||
// Login Form Handler
|
||||
class LoginFormHandler {
|
||||
constructor(authManager) {
|
||||
this.auth = authManager;
|
||||
this.form = $('#login-form');
|
||||
this.usernameInput = $('#login-username');
|
||||
this.passwordInput = $('#login-password');
|
||||
this.submitButton = this.form?.querySelector('button[type="submit"]');
|
||||
this.errorMessage = $('#login-error');
|
||||
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
if (!this.form) return;
|
||||
|
||||
this.form.addEventListener('submit', (e) => this.handleSubmit(e));
|
||||
|
||||
// Enter key handling
|
||||
this.passwordInput?.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
this.form.dispatchEvent(new Event('submit'));
|
||||
}
|
||||
});
|
||||
|
||||
// Clear error on input
|
||||
[this.usernameInput, this.passwordInput].forEach(input => {
|
||||
input?.addEventListener('input', () => this.clearError());
|
||||
});
|
||||
}
|
||||
|
||||
async handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const username = this.usernameInput?.value.trim();
|
||||
const password = this.passwordInput?.value;
|
||||
|
||||
if (!username || !password) {
|
||||
this.showError('Bitte Benutzername und Passwort eingeben.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.setLoading(true);
|
||||
this.clearError();
|
||||
|
||||
try {
|
||||
await this.auth.login(username, password);
|
||||
// Success - app will handle the redirect
|
||||
} catch (error) {
|
||||
this.showError(error.message || 'Anmeldung fehlgeschlagen.');
|
||||
this.passwordInput.value = '';
|
||||
this.passwordInput.focus();
|
||||
} finally {
|
||||
this.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
if (this.errorMessage) {
|
||||
this.errorMessage.textContent = message;
|
||||
this.errorMessage.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
clearError() {
|
||||
if (this.errorMessage) {
|
||||
this.errorMessage.textContent = '';
|
||||
this.errorMessage.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(loading) {
|
||||
if (this.submitButton) {
|
||||
this.submitButton.disabled = loading;
|
||||
this.submitButton.classList.toggle('loading', loading);
|
||||
}
|
||||
|
||||
if (this.usernameInput) this.usernameInput.disabled = loading;
|
||||
if (this.passwordInput) this.passwordInput.disabled = loading;
|
||||
}
|
||||
|
||||
reset() {
|
||||
if (this.form) this.form.reset();
|
||||
this.clearError();
|
||||
this.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// User Menu Handler
|
||||
class UserMenuHandler {
|
||||
constructor(authManager) {
|
||||
this.auth = authManager;
|
||||
this.userMenu = $('.user-menu');
|
||||
this.userAvatar = $('#user-avatar');
|
||||
this.userDropdown = $('.user-dropdown');
|
||||
this.userName = $('#user-name');
|
||||
this.userRole = $('#user-role');
|
||||
this.logoutButton = $('#btn-logout');
|
||||
this.changePasswordButton = $('#btn-change-password');
|
||||
|
||||
this.isOpen = false;
|
||||
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Toggle dropdown
|
||||
this.userAvatar?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggle();
|
||||
});
|
||||
|
||||
// Close on outside click
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!this.userMenu?.contains(e.target)) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Logout
|
||||
this.logoutButton?.addEventListener('click', () => this.handleLogout());
|
||||
|
||||
// Change password
|
||||
this.changePasswordButton?.addEventListener('click', () => {
|
||||
this.close();
|
||||
window.dispatchEvent(new CustomEvent('modal:open', {
|
||||
detail: { modalId: 'change-password-modal' }
|
||||
}));
|
||||
});
|
||||
|
||||
// Escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && this.isOpen) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
update() {
|
||||
const user = this.auth.getUser();
|
||||
|
||||
if (this.userAvatar) {
|
||||
this.userAvatar.textContent = this.auth.getUserInitials();
|
||||
this.userAvatar.style.backgroundColor = this.auth.getUserColor();
|
||||
}
|
||||
|
||||
if (this.userName) {
|
||||
this.userName.textContent = user?.displayName || user?.username || 'Benutzer';
|
||||
}
|
||||
|
||||
if (this.userRole) {
|
||||
let roleText = 'Benutzer';
|
||||
if (user?.role === 'admin') {
|
||||
roleText = 'Administrator';
|
||||
} else if (user?.permissions?.length > 0) {
|
||||
roleText = user.permissions.map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(', ');
|
||||
}
|
||||
this.userRole.textContent = roleText;
|
||||
}
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.isOpen) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
if (this.userDropdown) {
|
||||
this.userDropdown.classList.remove('hidden');
|
||||
this.isOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.userDropdown) {
|
||||
this.userDropdown.classList.add('hidden');
|
||||
this.isOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
async handleLogout() {
|
||||
this.close();
|
||||
|
||||
try {
|
||||
await this.auth.logout();
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Change Password Modal Handler
|
||||
class ChangePasswordHandler {
|
||||
constructor(authManager) {
|
||||
this.auth = authManager;
|
||||
this.modal = $('#change-password-modal');
|
||||
this.form = $('#change-password-form');
|
||||
this.currentPassword = $('#current-password');
|
||||
this.newPassword = $('#new-password');
|
||||
this.confirmPassword = $('#confirm-password');
|
||||
this.errorMessage = this.modal?.querySelector('.error-message');
|
||||
this.submitButton = this.form?.querySelector('button[type="submit"]');
|
||||
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
this.form?.addEventListener('submit', (e) => this.handleSubmit(e));
|
||||
|
||||
// Password strength indicator
|
||||
this.newPassword?.addEventListener('input', () => {
|
||||
this.updatePasswordStrength();
|
||||
});
|
||||
|
||||
// Confirm password validation
|
||||
this.confirmPassword?.addEventListener('input', () => {
|
||||
this.validateConfirmPassword();
|
||||
});
|
||||
}
|
||||
|
||||
async handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const currentPassword = this.currentPassword?.value;
|
||||
const newPassword = this.newPassword?.value;
|
||||
const confirmPassword = this.confirmPassword?.value;
|
||||
|
||||
// Validation
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
this.showError('Bitte alle Felder ausfüllen.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
this.showError('Das neue Passwort muss mindestens 8 Zeichen lang sein.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
this.showError('Die Passwörter stimmen nicht überein.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.setLoading(true);
|
||||
this.clearError();
|
||||
|
||||
try {
|
||||
await this.auth.changePassword(currentPassword, newPassword);
|
||||
|
||||
// Success
|
||||
this.reset();
|
||||
this.closeModal();
|
||||
|
||||
window.dispatchEvent(new CustomEvent('toast:show', {
|
||||
detail: {
|
||||
message: 'Passwort erfolgreich geändert.',
|
||||
type: 'success'
|
||||
}
|
||||
}));
|
||||
} catch (error) {
|
||||
this.showError(error.message || 'Fehler beim Ändern des Passworts.');
|
||||
} finally {
|
||||
this.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
updatePasswordStrength() {
|
||||
const password = this.newPassword?.value || '';
|
||||
const strengthIndicator = this.modal?.querySelector('.password-strength');
|
||||
|
||||
if (!strengthIndicator) return;
|
||||
|
||||
let strength = 0;
|
||||
if (password.length >= 8) strength++;
|
||||
if (password.length >= 12) strength++;
|
||||
if (/[A-Z]/.test(password)) strength++;
|
||||
if (/[a-z]/.test(password)) strength++;
|
||||
if (/[0-9]/.test(password)) strength++;
|
||||
if (/[^A-Za-z0-9]/.test(password)) strength++;
|
||||
|
||||
const levels = ['weak', 'fair', 'good', 'strong'];
|
||||
const level = Math.min(Math.floor(strength / 1.5), 3);
|
||||
|
||||
strengthIndicator.className = `password-strength ${levels[level]}`;
|
||||
strengthIndicator.dataset.strength = levels[level];
|
||||
}
|
||||
|
||||
validateConfirmPassword() {
|
||||
const newPassword = this.newPassword?.value;
|
||||
const confirmPassword = this.confirmPassword?.value;
|
||||
|
||||
if (confirmPassword && newPassword !== confirmPassword) {
|
||||
this.confirmPassword.setCustomValidity('Passwörter stimmen nicht überein');
|
||||
} else {
|
||||
this.confirmPassword.setCustomValidity('');
|
||||
}
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
if (this.errorMessage) {
|
||||
this.errorMessage.textContent = message;
|
||||
this.errorMessage.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
clearError() {
|
||||
if (this.errorMessage) {
|
||||
this.errorMessage.textContent = '';
|
||||
this.errorMessage.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(loading) {
|
||||
if (this.submitButton) {
|
||||
this.submitButton.disabled = loading;
|
||||
this.submitButton.classList.toggle('loading', loading);
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
if (this.form) this.form.reset();
|
||||
this.clearError();
|
||||
this.setLoading(false);
|
||||
}
|
||||
|
||||
closeModal() {
|
||||
window.dispatchEvent(new CustomEvent('modal:close', {
|
||||
detail: { modalId: 'change-password-modal' }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instances
|
||||
const authManager = new AuthManager();
|
||||
let loginFormHandler = null;
|
||||
let userMenuHandler = null;
|
||||
let changePasswordHandler = null;
|
||||
|
||||
// Initialize handlers when DOM is ready
|
||||
function initAuthHandlers() {
|
||||
loginFormHandler = new LoginFormHandler(authManager);
|
||||
userMenuHandler = new UserMenuHandler(authManager);
|
||||
changePasswordHandler = new ChangePasswordHandler(authManager);
|
||||
}
|
||||
|
||||
// Listen for DOM ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initAuthHandlers);
|
||||
} else {
|
||||
initAuthHandlers();
|
||||
}
|
||||
|
||||
// Listen for login event to update UI
|
||||
window.addEventListener('auth:login', () => {
|
||||
userMenuHandler?.update();
|
||||
});
|
||||
|
||||
export {
|
||||
authManager,
|
||||
loginFormHandler,
|
||||
userMenuHandler,
|
||||
changePasswordHandler
|
||||
};
|
||||
|
||||
export default authManager;
|
||||
1363
frontend/js/board.js
Normale Datei
1363
frontend/js/board.js
Normale Datei
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
1006
frontend/js/calendar.js
Normale Datei
1006
frontend/js/calendar.js
Normale Datei
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
335
frontend/js/dashboard.js
Normale Datei
335
frontend/js/dashboard.js
Normale Datei
@ -0,0 +1,335 @@
|
||||
/**
|
||||
* 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' }
|
||||
}, [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;
|
||||
851
frontend/js/gitea.js
Normale Datei
851
frontend/js/gitea.js
Normale Datei
@ -0,0 +1,851 @@
|
||||
/**
|
||||
* TASKMATE - Gitea Manager
|
||||
* ========================
|
||||
* Git-Repository-Verwaltung pro Projekt
|
||||
*/
|
||||
|
||||
import api from './api.js';
|
||||
import { $, $$, escapeHtml } from './utils.js';
|
||||
import store from './store.js';
|
||||
|
||||
class GiteaManager {
|
||||
constructor() {
|
||||
this.application = null;
|
||||
this.gitStatus = null;
|
||||
this.branches = [];
|
||||
this.commits = [];
|
||||
this.giteaRepos = [];
|
||||
this.giteaConnected = false;
|
||||
this.initialized = false;
|
||||
this.refreshInterval = null;
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// DOM Elements
|
||||
this.giteaView = $('#view-gitea');
|
||||
this.noProjectSection = $('#gitea-no-project');
|
||||
this.configSection = $('#gitea-config-section');
|
||||
this.mainSection = $('#gitea-main-section');
|
||||
this.connectionStatus = $('#gitea-connection-status');
|
||||
|
||||
this.bindEvents();
|
||||
this.subscribeToStore();
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Konfiguration speichern
|
||||
$('#gitea-config-form')?.addEventListener('submit', (e) => this.handleConfigSave(e));
|
||||
|
||||
// Repository auswählen
|
||||
$('#gitea-repo-select')?.addEventListener('change', (e) => this.handleRepoSelect(e));
|
||||
|
||||
// Repositories aktualisieren
|
||||
$('#btn-refresh-repos')?.addEventListener('click', () => this.loadGiteaRepos());
|
||||
|
||||
// Neues Repository erstellen
|
||||
$('#btn-create-repo')?.addEventListener('click', () => this.openCreateRepoModal());
|
||||
|
||||
// Git-Operationen
|
||||
$('#btn-git-fetch')?.addEventListener('click', () => this.handleFetch());
|
||||
$('#btn-git-pull')?.addEventListener('click', () => this.handlePull());
|
||||
$('#btn-git-push')?.addEventListener('click', () => this.handlePush());
|
||||
$('#btn-git-commit')?.addEventListener('click', () => this.openCommitModal());
|
||||
|
||||
// Branch wechseln
|
||||
$('#branch-select')?.addEventListener('change', (e) => this.handleBranchChange(e));
|
||||
|
||||
// Pfad validieren
|
||||
$('#local-path-input')?.addEventListener('blur', (e) => this.validateLocalPath(e.target.value));
|
||||
|
||||
// Konfiguration bearbeiten/entfernen
|
||||
$('#btn-edit-config')?.addEventListener('click', () => this.showConfigSection());
|
||||
$('#btn-remove-config')?.addEventListener('click', () => this.handleRemoveConfig());
|
||||
|
||||
// Create Repo Modal
|
||||
$('#create-repo-form')?.addEventListener('submit', (e) => this.handleCreateRepo(e));
|
||||
|
||||
// Commit Modal
|
||||
$('#git-commit-form')?.addEventListener('submit', (e) => this.handleCommit(e));
|
||||
}
|
||||
|
||||
subscribeToStore() {
|
||||
// Bei Projektwechsel neu laden
|
||||
store.subscribe('currentProjectId', async (projectId) => {
|
||||
if (projectId && store.get('currentView') === 'gitea') {
|
||||
await this.loadApplication();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadApplication() {
|
||||
const projectId = store.get('currentProjectId');
|
||||
|
||||
if (!projectId) {
|
||||
this.showNoProjectMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
this.showLoading();
|
||||
|
||||
try {
|
||||
const result = await api.getProjectApplication(projectId);
|
||||
this.application = result;
|
||||
|
||||
if (result.configured) {
|
||||
await this.loadGitData();
|
||||
this.renderConfiguredView();
|
||||
} else {
|
||||
await this.loadGiteaRepos();
|
||||
this.renderConfigurationView();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Gitea] Fehler beim Laden:', error);
|
||||
this.showError('Fehler beim Laden der Konfiguration');
|
||||
}
|
||||
}
|
||||
|
||||
async loadGitData() {
|
||||
const projectId = store.get('currentProjectId');
|
||||
|
||||
try {
|
||||
const [statusResult, branchesResult, commitsResult] = await Promise.allSettled([
|
||||
api.getGitStatus(projectId),
|
||||
api.getGitBranches(projectId),
|
||||
api.getGitCommits(projectId, 10)
|
||||
]);
|
||||
|
||||
if (statusResult.status === 'fulfilled') {
|
||||
this.gitStatus = statusResult.value;
|
||||
} else {
|
||||
this.gitStatus = null;
|
||||
console.error('[Gitea] Status-Fehler:', statusResult.reason);
|
||||
}
|
||||
|
||||
if (branchesResult.status === 'fulfilled') {
|
||||
this.branches = branchesResult.value.branches || [];
|
||||
} else {
|
||||
this.branches = [];
|
||||
}
|
||||
|
||||
if (commitsResult.status === 'fulfilled') {
|
||||
this.commits = commitsResult.value.commits || [];
|
||||
} else {
|
||||
this.commits = [];
|
||||
}
|
||||
|
||||
this.renderStatus();
|
||||
this.renderBranches();
|
||||
this.renderCommits();
|
||||
this.renderChanges();
|
||||
} catch (error) {
|
||||
console.error('[Gitea] Git-Daten laden fehlgeschlagen:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async loadGiteaRepos() {
|
||||
try {
|
||||
const result = await api.testGiteaConnection();
|
||||
this.giteaConnected = result.connected;
|
||||
this.updateConnectionStatus(result);
|
||||
|
||||
if (this.giteaConnected) {
|
||||
const reposResult = await api.getGiteaRepositories();
|
||||
this.giteaRepos = reposResult.repositories || [];
|
||||
this.populateRepoSelect();
|
||||
}
|
||||
} catch (error) {
|
||||
this.giteaConnected = false;
|
||||
this.updateConnectionStatus({ connected: false, error: error.message });
|
||||
console.error('[Gitea] Verbindung fehlgeschlagen:', error);
|
||||
}
|
||||
}
|
||||
|
||||
updateConnectionStatus(result) {
|
||||
const statusEl = this.connectionStatus;
|
||||
if (!statusEl) return;
|
||||
|
||||
statusEl.classList.remove('connected', 'disconnected');
|
||||
|
||||
if (result.connected) {
|
||||
statusEl.classList.add('connected');
|
||||
statusEl.querySelector('.status-text').textContent =
|
||||
`Verbunden als ${result.user?.login || 'Benutzer'}`;
|
||||
} else {
|
||||
statusEl.classList.add('disconnected');
|
||||
statusEl.querySelector('.status-text').textContent =
|
||||
result.error || 'Verbindung fehlgeschlagen';
|
||||
}
|
||||
}
|
||||
|
||||
populateRepoSelect() {
|
||||
const select = $('#gitea-repo-select');
|
||||
if (!select) return;
|
||||
|
||||
select.innerHTML = '<option value="">-- Repository wählen --</option>';
|
||||
|
||||
this.giteaRepos.forEach(repo => {
|
||||
const option = document.createElement('option');
|
||||
option.value = JSON.stringify({
|
||||
url: repo.cloneUrl,
|
||||
owner: repo.owner,
|
||||
name: repo.name,
|
||||
fullName: repo.fullName,
|
||||
htmlUrl: repo.htmlUrl,
|
||||
defaultBranch: repo.defaultBranch
|
||||
});
|
||||
option.textContent = repo.fullName;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
handleRepoSelect(e) {
|
||||
const value = e.target.value;
|
||||
if (!value) return;
|
||||
|
||||
try {
|
||||
const repo = JSON.parse(value);
|
||||
$('#default-branch-input').value = repo.defaultBranch || 'main';
|
||||
} catch (error) {
|
||||
console.error('[Gitea] Fehler beim Parsen des Repository:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async validateLocalPath(path) {
|
||||
const resultEl = $('#path-validation-result');
|
||||
if (!resultEl || !path) {
|
||||
if (resultEl) {
|
||||
resultEl.textContent = '';
|
||||
resultEl.className = 'form-hint';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.validatePath(path);
|
||||
|
||||
if (result.valid) {
|
||||
if (result.isRepository) {
|
||||
resultEl.textContent = 'Pfad ist ein Git-Repository';
|
||||
resultEl.className = 'form-hint success';
|
||||
} else {
|
||||
resultEl.textContent = 'Pfad ist erreichbar (kein Git-Repository)';
|
||||
resultEl.className = 'form-hint';
|
||||
}
|
||||
} else {
|
||||
resultEl.textContent = 'Pfad nicht erreichbar';
|
||||
resultEl.className = 'form-hint error';
|
||||
}
|
||||
} catch (error) {
|
||||
resultEl.textContent = 'Fehler bei der Validierung';
|
||||
resultEl.className = 'form-hint error';
|
||||
}
|
||||
}
|
||||
|
||||
async handleConfigSave(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const projectId = store.get('currentProjectId');
|
||||
if (!projectId) return;
|
||||
|
||||
const repoSelectValue = $('#gitea-repo-select').value;
|
||||
const localPath = $('#local-path-input').value.trim();
|
||||
const defaultBranch = $('#default-branch-input').value.trim() || 'main';
|
||||
|
||||
if (!localPath) {
|
||||
this.showToast('Bitte geben Sie einen lokalen Pfad an', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
let giteaRepoUrl = null;
|
||||
let giteaRepoOwner = null;
|
||||
let giteaRepoName = null;
|
||||
let cloneUrl = null;
|
||||
|
||||
if (repoSelectValue) {
|
||||
try {
|
||||
const repo = JSON.parse(repoSelectValue);
|
||||
giteaRepoUrl = repo.htmlUrl;
|
||||
giteaRepoOwner = repo.owner;
|
||||
giteaRepoName = repo.name;
|
||||
cloneUrl = repo.url; // Clone URL für git remote
|
||||
} catch (error) {
|
||||
console.error('[Gitea] Fehler beim Parsen des Repository:', error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Konfiguration speichern
|
||||
const result = await api.saveProjectApplication({
|
||||
projectId,
|
||||
localPath,
|
||||
giteaRepoUrl,
|
||||
giteaRepoOwner,
|
||||
giteaRepoName,
|
||||
defaultBranch
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// 2. Repository für Gitea vorbereiten (init, remote setzen)
|
||||
if (cloneUrl) {
|
||||
this.showToast('Repository wird vorbereitet...', 'info');
|
||||
const prepareResult = await api.prepareRepository(projectId, cloneUrl, defaultBranch);
|
||||
|
||||
if (prepareResult.success) {
|
||||
this.showToast('Konfiguration gespeichert und Repository vorbereitet', 'success');
|
||||
} else {
|
||||
this.showToast('Konfiguration gespeichert, aber Repository-Vorbereitung fehlgeschlagen: ' + (prepareResult.error || 'Unbekannter Fehler'), 'warning');
|
||||
}
|
||||
} else {
|
||||
this.showToast('Konfiguration gespeichert', 'success');
|
||||
}
|
||||
|
||||
await this.loadApplication();
|
||||
} else {
|
||||
this.showToast(result.error || 'Fehler beim Speichern', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showToast(error.message || 'Fehler beim Speichern', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async handleRemoveConfig() {
|
||||
const projectId = store.get('currentProjectId');
|
||||
if (!projectId) return;
|
||||
|
||||
if (!confirm('Möchten Sie die Repository-Konfiguration wirklich entfernen?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.deleteProjectApplication(projectId);
|
||||
this.showToast('Konfiguration entfernt', 'success');
|
||||
await this.loadApplication();
|
||||
} catch (error) {
|
||||
this.showToast(error.message || 'Fehler beim Entfernen', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
showConfigSection() {
|
||||
this.hideAllSections();
|
||||
this.configSection?.classList.remove('hidden');
|
||||
|
||||
// Formular mit aktuellen Werten füllen
|
||||
if (this.application?.configured) {
|
||||
$('#local-path-input').value = this.application.local_path || '';
|
||||
$('#default-branch-input').value = this.application.default_branch || 'main';
|
||||
|
||||
// Repository im Dropdown auswählen falls vorhanden
|
||||
if (this.application.gitea_repo_url) {
|
||||
const select = $('#gitea-repo-select');
|
||||
const options = select?.querySelectorAll('option');
|
||||
options?.forEach(option => {
|
||||
if (option.value) {
|
||||
try {
|
||||
const repo = JSON.parse(option.value);
|
||||
if (repo.owner === this.application.gitea_repo_owner &&
|
||||
repo.name === this.application.gitea_repo_name) {
|
||||
select.value = option.value;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.loadGiteaRepos();
|
||||
}
|
||||
|
||||
// Git Operations
|
||||
async handleFetch() {
|
||||
const projectId = store.get('currentProjectId');
|
||||
if (!projectId) return;
|
||||
|
||||
this.setOperationLoading('fetch', true);
|
||||
|
||||
try {
|
||||
const result = await api.gitFetch(projectId);
|
||||
if (result.success) {
|
||||
this.showToast('Fetch erfolgreich', 'success');
|
||||
await this.loadGitData();
|
||||
} else {
|
||||
this.showToast(result.error || 'Fetch fehlgeschlagen', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showToast(error.message || 'Fetch fehlgeschlagen', 'error');
|
||||
} finally {
|
||||
this.setOperationLoading('fetch', false);
|
||||
}
|
||||
}
|
||||
|
||||
async handlePull() {
|
||||
const projectId = store.get('currentProjectId');
|
||||
if (!projectId) return;
|
||||
|
||||
this.setOperationLoading('pull', true);
|
||||
|
||||
try {
|
||||
const branch = $('#branch-select')?.value || null;
|
||||
const result = await api.gitPull(projectId, branch);
|
||||
|
||||
if (result.success) {
|
||||
this.showToast('Pull erfolgreich', 'success');
|
||||
await this.loadGitData();
|
||||
} else {
|
||||
this.showToast(result.error || 'Pull fehlgeschlagen', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showToast(error.message || 'Pull fehlgeschlagen', 'error');
|
||||
} finally {
|
||||
this.setOperationLoading('pull', false);
|
||||
}
|
||||
}
|
||||
|
||||
async handlePush() {
|
||||
const projectId = store.get('currentProjectId');
|
||||
if (!projectId) return;
|
||||
|
||||
this.setOperationLoading('push', true);
|
||||
|
||||
try {
|
||||
const branch = $('#branch-select')?.value || 'main';
|
||||
let result = await api.gitPush(projectId, branch);
|
||||
|
||||
// Falls Push wegen fehlendem Upstream/Remote fehlschlägt, versuche init-push
|
||||
if (!result.success && result.error &&
|
||||
(result.error.includes('No configured push destination') ||
|
||||
result.error.includes('no upstream') ||
|
||||
result.error.includes('Kein Remote'))) {
|
||||
this.showToast('Kein Upstream konfiguriert, führe initialen Push durch...', 'info');
|
||||
result = await api.gitInitPush(projectId, branch);
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
this.showToast('Push erfolgreich', 'success');
|
||||
await this.loadGitData();
|
||||
} else {
|
||||
this.showToast(result.error || 'Push fehlgeschlagen', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showToast(error.message || 'Push fehlgeschlagen', 'error');
|
||||
} finally {
|
||||
this.setOperationLoading('push', false);
|
||||
}
|
||||
}
|
||||
|
||||
openCommitModal() {
|
||||
const modal = $('#git-commit-modal');
|
||||
if (!modal) return;
|
||||
|
||||
$('#commit-message').value = '';
|
||||
$('#commit-stage-all').checked = true;
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
modal.classList.add('visible');
|
||||
$('#modal-overlay')?.classList.remove('hidden');
|
||||
|
||||
store.openModal('git-commit-modal');
|
||||
}
|
||||
|
||||
async handleCommit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const projectId = store.get('currentProjectId');
|
||||
if (!projectId) return;
|
||||
|
||||
const message = $('#commit-message')?.value.trim();
|
||||
const stageAll = $('#commit-stage-all')?.checked ?? true;
|
||||
|
||||
if (!message) {
|
||||
this.showToast('Bitte geben Sie eine Commit-Nachricht ein', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.gitCommit(projectId, message, stageAll);
|
||||
|
||||
if (result.success) {
|
||||
this.showToast('Commit erstellt', 'success');
|
||||
this.closeModal('git-commit-modal');
|
||||
await this.loadGitData();
|
||||
} else {
|
||||
this.showToast(result.error || 'Commit fehlgeschlagen', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showToast(error.message || 'Commit fehlgeschlagen', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async handleBranchChange(e) {
|
||||
const projectId = store.get('currentProjectId');
|
||||
const branch = e.target.value;
|
||||
|
||||
if (!projectId || !branch) return;
|
||||
|
||||
try {
|
||||
const result = await api.gitCheckout(projectId, branch);
|
||||
|
||||
if (result.success) {
|
||||
this.showToast(`Gewechselt zu ${branch}`, 'success');
|
||||
await this.loadGitData();
|
||||
} else {
|
||||
this.showToast(result.error || 'Branch-Wechsel fehlgeschlagen', 'error');
|
||||
// Zurück zum vorherigen Branch
|
||||
await this.loadGitData();
|
||||
}
|
||||
} catch (error) {
|
||||
this.showToast(error.message || 'Branch-Wechsel fehlgeschlagen', 'error');
|
||||
await this.loadGitData();
|
||||
}
|
||||
}
|
||||
|
||||
openCreateRepoModal() {
|
||||
const modal = $('#create-repo-modal');
|
||||
if (!modal) return;
|
||||
|
||||
$('#new-repo-name').value = '';
|
||||
$('#new-repo-description').value = '';
|
||||
$('#new-repo-private').checked = true;
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
modal.classList.add('visible');
|
||||
$('#modal-overlay')?.classList.remove('hidden');
|
||||
|
||||
store.openModal('create-repo-modal');
|
||||
}
|
||||
|
||||
async handleCreateRepo(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const name = $('#new-repo-name')?.value.trim();
|
||||
const description = $('#new-repo-description')?.value.trim();
|
||||
const isPrivate = $('#new-repo-private')?.checked ?? true;
|
||||
|
||||
if (!name) {
|
||||
this.showToast('Bitte geben Sie einen Repository-Namen ein', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.createGiteaRepository({
|
||||
name,
|
||||
description,
|
||||
private: isPrivate
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
this.showToast('Repository erstellt', 'success');
|
||||
this.closeModal('create-repo-modal');
|
||||
await this.loadGiteaRepos();
|
||||
|
||||
// Neues Repository im Dropdown auswählen
|
||||
const select = $('#gitea-repo-select');
|
||||
if (select && result.repository) {
|
||||
const option = document.createElement('option');
|
||||
option.value = JSON.stringify({
|
||||
url: result.repository.cloneUrl,
|
||||
owner: result.repository.owner,
|
||||
name: result.repository.name,
|
||||
fullName: result.repository.fullName,
|
||||
htmlUrl: result.repository.htmlUrl,
|
||||
defaultBranch: result.repository.defaultBranch
|
||||
});
|
||||
option.textContent = result.repository.fullName;
|
||||
select.appendChild(option);
|
||||
select.value = option.value;
|
||||
}
|
||||
} else {
|
||||
this.showToast(result.error || 'Repository-Erstellung fehlgeschlagen', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showToast(error.message || 'Repository-Erstellung fehlgeschlagen', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Rendering
|
||||
renderConfigurationView() {
|
||||
this.hideAllSections();
|
||||
this.configSection?.classList.remove('hidden');
|
||||
|
||||
// Formular zurücksetzen
|
||||
$('#gitea-repo-select').value = '';
|
||||
$('#local-path-input').value = '';
|
||||
$('#default-branch-input').value = 'main';
|
||||
$('#path-validation-result').textContent = '';
|
||||
}
|
||||
|
||||
renderConfiguredView() {
|
||||
this.hideAllSections();
|
||||
this.mainSection?.classList.remove('hidden');
|
||||
|
||||
// Repository-Info
|
||||
const repoNameEl = $('#gitea-repo-name span');
|
||||
const repoUrlEl = $('#gitea-repo-url');
|
||||
const localPathEl = $('#gitea-local-path-display');
|
||||
|
||||
if (this.application) {
|
||||
if (this.application.gitea_repo_owner && this.application.gitea_repo_name) {
|
||||
repoNameEl.textContent = `${this.application.gitea_repo_owner}/${this.application.gitea_repo_name}`;
|
||||
} else {
|
||||
repoNameEl.textContent = 'Lokales Repository';
|
||||
}
|
||||
|
||||
if (this.application.gitea_repo_url) {
|
||||
repoUrlEl.href = this.application.gitea_repo_url;
|
||||
repoUrlEl.textContent = this.application.gitea_repo_url;
|
||||
repoUrlEl.classList.remove('hidden');
|
||||
} else {
|
||||
repoUrlEl.classList.add('hidden');
|
||||
}
|
||||
|
||||
localPathEl.textContent = this.application.local_path || '-';
|
||||
}
|
||||
}
|
||||
|
||||
renderStatus() {
|
||||
const statusBadge = $('#git-status-indicator');
|
||||
const changesCount = $('#git-changes-count');
|
||||
const aheadBehind = $('#git-ahead-behind');
|
||||
|
||||
if (!this.gitStatus) {
|
||||
statusBadge.textContent = 'Fehler';
|
||||
statusBadge.className = 'status-badge error';
|
||||
changesCount.textContent = '-';
|
||||
aheadBehind.textContent = '- / -';
|
||||
return;
|
||||
}
|
||||
|
||||
// Status Badge
|
||||
if (!this.gitStatus.success) {
|
||||
statusBadge.textContent = 'Fehler';
|
||||
statusBadge.className = 'status-badge error';
|
||||
} else if (this.gitStatus.isClean) {
|
||||
statusBadge.textContent = 'Sauber';
|
||||
statusBadge.className = 'status-badge clean';
|
||||
} else if (this.gitStatus.hasChanges) {
|
||||
statusBadge.textContent = 'Geändert';
|
||||
statusBadge.className = 'status-badge dirty';
|
||||
} else if (this.gitStatus.ahead > 0) {
|
||||
statusBadge.textContent = 'Voraus';
|
||||
statusBadge.className = 'status-badge ahead';
|
||||
} else {
|
||||
statusBadge.textContent = 'OK';
|
||||
statusBadge.className = 'status-badge clean';
|
||||
}
|
||||
|
||||
// Änderungen
|
||||
const changes = this.gitStatus.changes || [];
|
||||
changesCount.textContent = changes.length;
|
||||
|
||||
// Ahead/Behind
|
||||
const ahead = this.gitStatus.ahead || 0;
|
||||
const behind = this.gitStatus.behind || 0;
|
||||
aheadBehind.textContent = `${ahead} / ${behind}`;
|
||||
}
|
||||
|
||||
renderBranches() {
|
||||
const select = $('#branch-select');
|
||||
if (!select) return;
|
||||
|
||||
const currentBranch = this.gitStatus?.branch || 'main';
|
||||
|
||||
select.innerHTML = '';
|
||||
|
||||
// Lokale Branches zuerst
|
||||
const localBranches = this.branches.filter(b => !b.isRemote);
|
||||
localBranches.forEach(branch => {
|
||||
const option = document.createElement('option');
|
||||
option.value = branch.name;
|
||||
option.textContent = branch.name;
|
||||
if (branch.name === currentBranch) {
|
||||
option.selected = true;
|
||||
}
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
renderCommits() {
|
||||
const listEl = $('#git-commits-list');
|
||||
if (!listEl) return;
|
||||
|
||||
if (this.commits.length === 0) {
|
||||
listEl.innerHTML = '<div class="gitea-empty-state" style="padding: var(--spacing-4);"><p>Keine Commits gefunden</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = this.commits.map(commit => `
|
||||
<div class="commit-item">
|
||||
<span class="commit-hash">${escapeHtml(commit.shortHash || commit.sha?.substring(0, 7))}</span>
|
||||
<div class="commit-info">
|
||||
<div class="commit-message">${escapeHtml(commit.message?.split('\n')[0] || '')}</div>
|
||||
<div class="commit-meta">
|
||||
<span class="author">${escapeHtml(commit.author)}</span> · ${this.formatDate(commit.date)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
renderChanges() {
|
||||
const changesSection = $('#gitea-changes-section');
|
||||
const listEl = $('#git-changes-list');
|
||||
|
||||
if (!changesSection || !listEl) return;
|
||||
|
||||
const changes = this.gitStatus?.changes || [];
|
||||
|
||||
if (changes.length === 0) {
|
||||
changesSection.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
changesSection.classList.remove('hidden');
|
||||
|
||||
listEl.innerHTML = changes.map(change => {
|
||||
const statusClass = this.getChangeStatusClass(change.status);
|
||||
const statusLabel = this.getChangeStatusLabel(change.status);
|
||||
|
||||
return `
|
||||
<div class="change-item">
|
||||
<span class="change-status ${statusClass}" title="${statusLabel}">${change.status}</span>
|
||||
<span class="change-file">${escapeHtml(change.file)}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
getChangeStatusClass(status) {
|
||||
const first = status.charAt(0);
|
||||
const second = status.charAt(1);
|
||||
|
||||
if (first === 'M' || second === 'M') return 'modified';
|
||||
if (first === 'A' || second === 'A') return 'added';
|
||||
if (first === 'D' || second === 'D') return 'deleted';
|
||||
if (first === 'R' || second === 'R') return 'renamed';
|
||||
if (first === '?' || second === '?') return 'untracked';
|
||||
return '';
|
||||
}
|
||||
|
||||
getChangeStatusLabel(status) {
|
||||
const first = status.charAt(0);
|
||||
const second = status.charAt(1);
|
||||
|
||||
if (first === 'M' || second === 'M') return 'Geändert';
|
||||
if (first === 'A' || second === 'A') return 'Hinzugefügt';
|
||||
if (first === 'D' || second === 'D') return 'Gelöscht';
|
||||
if (first === 'R' || second === 'R') return 'Umbenannt';
|
||||
if (first === '?' || second === '?') return 'Nicht verfolgt';
|
||||
return status;
|
||||
}
|
||||
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'gerade eben';
|
||||
if (diffMins < 60) return `vor ${diffMins} Minute${diffMins !== 1 ? 'n' : ''}`;
|
||||
if (diffHours < 24) return `vor ${diffHours} Stunde${diffHours !== 1 ? 'n' : ''}`;
|
||||
if (diffDays < 7) return `vor ${diffDays} Tag${diffDays !== 1 ? 'en' : ''}`;
|
||||
|
||||
// Lokale Formatierung
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
// UI Helpers
|
||||
hideAllSections() {
|
||||
this.noProjectSection?.classList.add('hidden');
|
||||
this.configSection?.classList.add('hidden');
|
||||
this.mainSection?.classList.add('hidden');
|
||||
}
|
||||
|
||||
showNoProjectMessage() {
|
||||
this.hideAllSections();
|
||||
this.noProjectSection?.classList.remove('hidden');
|
||||
}
|
||||
|
||||
showLoading() {
|
||||
this.isLoading = true;
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
this.showToast(message, 'error');
|
||||
}
|
||||
|
||||
showToast(message, type = 'info') {
|
||||
window.dispatchEvent(new CustomEvent('toast:show', {
|
||||
detail: { message, type }
|
||||
}));
|
||||
}
|
||||
|
||||
closeModal(modalId) {
|
||||
const modal = $(`#${modalId}`);
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('visible');
|
||||
}
|
||||
$('#modal-overlay')?.classList.add('hidden');
|
||||
store.closeModal(modalId);
|
||||
}
|
||||
|
||||
setOperationLoading(operation, loading) {
|
||||
const buttonId = `btn-git-${operation}`;
|
||||
const button = $(`#${buttonId}`);
|
||||
if (button) {
|
||||
button.disabled = loading;
|
||||
if (loading) {
|
||||
button.classList.add('loading');
|
||||
} else {
|
||||
button.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// View Control
|
||||
show() {
|
||||
this.giteaView?.classList.remove('hidden');
|
||||
this.giteaView?.classList.add('active');
|
||||
this.loadApplication();
|
||||
this.startAutoRefresh();
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.giteaView?.classList.add('hidden');
|
||||
this.giteaView?.classList.remove('active');
|
||||
this.stopAutoRefresh();
|
||||
}
|
||||
|
||||
startAutoRefresh() {
|
||||
this.stopAutoRefresh();
|
||||
// Status alle 30 Sekunden aktualisieren
|
||||
this.refreshInterval = setInterval(() => {
|
||||
if (this.application?.configured && !this.isLoading) {
|
||||
this.loadGitData();
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
stopAutoRefresh() {
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
this.refreshInterval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const giteaManager = new GiteaManager();
|
||||
export { giteaManager };
|
||||
export default giteaManager;
|
||||
641
frontend/js/list.js
Normale Datei
641
frontend/js/list.js
Normale Datei
@ -0,0 +1,641 @@
|
||||
/**
|
||||
* TASKMATE - List View Module
|
||||
* ===========================
|
||||
* Tabellarische Listenansicht der Aufgaben
|
||||
* Unterstützt gruppierte und flache Ansicht mit Inline-Bearbeitung
|
||||
*/
|
||||
|
||||
import store from './store.js';
|
||||
import api from './api.js';
|
||||
import {
|
||||
$, $$, createElement, clearElement, formatDate,
|
||||
getDueDateStatus, filterTasks, getInitials, hexToRgba,
|
||||
getContrastColor, groupBy, sortBy, escapeHtml
|
||||
} from './utils.js';
|
||||
|
||||
class ListViewManager {
|
||||
constructor() {
|
||||
// DOM Elements
|
||||
this.container = null;
|
||||
this.contentElement = null;
|
||||
this.sortSelect = null;
|
||||
this.sortDirectionBtn = null;
|
||||
|
||||
// State
|
||||
this.viewMode = 'grouped'; // 'grouped' | 'flat'
|
||||
this.sortColumn = 'dueDate';
|
||||
this.sortDirection = 'asc';
|
||||
this.collapsedGroups = new Set();
|
||||
|
||||
// Inline editing state
|
||||
this.editingCell = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.container = $('#view-list');
|
||||
this.contentElement = $('#list-content');
|
||||
this.sortSelect = $('#list-sort-select');
|
||||
this.sortDirectionBtn = $('#list-sort-direction');
|
||||
|
||||
if (!this.container) return;
|
||||
|
||||
this.bindEvents();
|
||||
|
||||
// Subscribe to store changes for real-time updates
|
||||
store.subscribe('tasks', () => this.render());
|
||||
store.subscribe('columns', () => this.render());
|
||||
store.subscribe('filters', () => this.render());
|
||||
store.subscribe('searchResultIds', () => this.render());
|
||||
store.subscribe('users', () => this.render());
|
||||
store.subscribe('labels', () => this.render());
|
||||
store.subscribe('currentView', (view) => {
|
||||
if (view === 'list') this.render();
|
||||
});
|
||||
|
||||
// Listen for app refresh events
|
||||
window.addEventListener('app:refresh', () => this.render());
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// View mode toggle
|
||||
$$('.list-toggle-btn', this.container).forEach(btn => {
|
||||
btn.addEventListener('click', () => this.setViewMode(btn.dataset.mode));
|
||||
});
|
||||
|
||||
// Sort select
|
||||
if (this.sortSelect) {
|
||||
this.sortSelect.addEventListener('change', () => {
|
||||
this.sortColumn = this.sortSelect.value;
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
// Sort direction button
|
||||
if (this.sortDirectionBtn) {
|
||||
this.sortDirectionBtn.addEventListener('click', () => {
|
||||
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
this.sortDirectionBtn.classList.toggle('asc', this.sortDirection === 'asc');
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
// Delegate click events on content
|
||||
if (this.contentElement) {
|
||||
this.contentElement.addEventListener('click', (e) => this.handleContentClick(e));
|
||||
this.contentElement.addEventListener('change', (e) => this.handleContentChange(e));
|
||||
this.contentElement.addEventListener('dblclick', (e) => this.handleDoubleClick(e));
|
||||
}
|
||||
}
|
||||
|
||||
setViewMode(mode) {
|
||||
this.viewMode = mode;
|
||||
|
||||
// Update toggle buttons
|
||||
$$('.list-toggle-btn', this.container).forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.mode === mode);
|
||||
});
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
// =====================
|
||||
// RENDERING
|
||||
// =====================
|
||||
|
||||
render() {
|
||||
if (!this.contentElement) return;
|
||||
if (store.get('currentView') !== 'list') return;
|
||||
|
||||
const tasks = this.getFilteredAndSortedTasks();
|
||||
|
||||
if (tasks.length === 0) {
|
||||
this.renderEmpty();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.viewMode === 'grouped') {
|
||||
this.renderGrouped(tasks);
|
||||
} else {
|
||||
this.renderFlat(tasks);
|
||||
}
|
||||
}
|
||||
|
||||
getFilteredAndSortedTasks() {
|
||||
const tasks = store.get('tasks').filter(t => !t.archived);
|
||||
const filters = store.get('filters');
|
||||
const searchResultIds = store.get('searchResultIds') || [];
|
||||
const columns = store.get('columns');
|
||||
|
||||
// Apply filters
|
||||
let filtered = filterTasks(tasks, filters, searchResultIds, columns);
|
||||
|
||||
// Apply sorting
|
||||
filtered = this.sortTasks(filtered);
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
sortTasks(tasks) {
|
||||
const columns = store.get('columns');
|
||||
const users = store.get('users');
|
||||
|
||||
return sortBy(tasks, (task) => {
|
||||
switch (this.sortColumn) {
|
||||
case 'title':
|
||||
return task.title?.toLowerCase() || '';
|
||||
case 'priority':
|
||||
const priorityOrder = { high: 0, medium: 1, low: 2 };
|
||||
return priorityOrder[task.priority] ?? 1;
|
||||
case 'dueDate':
|
||||
return task.dueDate ? new Date(task.dueDate).getTime() : Infinity;
|
||||
case 'status':
|
||||
const colIndex = columns.findIndex(c => c.id === task.columnId);
|
||||
return colIndex >= 0 ? colIndex : Infinity;
|
||||
case 'assignee':
|
||||
const user = users.find(u => u.id === task.assignedTo);
|
||||
return user?.displayName?.toLowerCase() || 'zzz';
|
||||
default:
|
||||
return task.title?.toLowerCase() || '';
|
||||
}
|
||||
}, this.sortDirection);
|
||||
}
|
||||
|
||||
renderEmpty() {
|
||||
this.contentElement.innerHTML = `
|
||||
<div class="list-empty">
|
||||
<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" stroke="currentColor" stroke-width="2" fill="none"/><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||
<h3>Keine Aufgaben gefunden</h3>
|
||||
<p>Erstellen Sie eine neue Aufgabe oder ändern Sie die Filter.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderGrouped(tasks) {
|
||||
const columns = store.get('columns');
|
||||
const tasksByColumn = groupBy(tasks, 'columnId');
|
||||
|
||||
clearElement(this.contentElement);
|
||||
|
||||
columns.forEach(column => {
|
||||
const columnTasks = tasksByColumn[column.id] || [];
|
||||
if (columnTasks.length === 0) return;
|
||||
|
||||
const isCollapsed = this.collapsedGroups.has(column.id);
|
||||
|
||||
const group = createElement('div', { className: 'list-group' });
|
||||
|
||||
// Group header
|
||||
const header = createElement('div', {
|
||||
className: `list-group-header ${isCollapsed ? 'collapsed' : ''}`,
|
||||
dataset: { columnId: column.id }
|
||||
});
|
||||
|
||||
header.innerHTML = `
|
||||
<svg viewBox="0 0 24 24"><path d="m6 9 6 6 6-6" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||
<span class="list-group-color" style="background-color: ${column.color || '#6366F1'}"></span>
|
||||
<span class="list-group-title">${escapeHtml(column.name)}</span>
|
||||
<span class="list-group-count">${columnTasks.length} Aufgabe${columnTasks.length !== 1 ? 'n' : ''}</span>
|
||||
`;
|
||||
|
||||
header.addEventListener('click', () => this.toggleGroup(column.id));
|
||||
|
||||
group.appendChild(header);
|
||||
|
||||
// Group content (table)
|
||||
const content = createElement('div', {
|
||||
className: `list-group-content ${isCollapsed ? 'collapsed' : ''}`
|
||||
});
|
||||
|
||||
// Table header
|
||||
content.appendChild(this.renderTableHeader());
|
||||
|
||||
// Table rows
|
||||
columnTasks.forEach(task => {
|
||||
content.appendChild(this.renderTableRow(task, column));
|
||||
});
|
||||
|
||||
group.appendChild(content);
|
||||
this.contentElement.appendChild(group);
|
||||
});
|
||||
}
|
||||
|
||||
renderFlat(tasks) {
|
||||
clearElement(this.contentElement);
|
||||
|
||||
const tableContainer = createElement('div', { className: 'list-table' });
|
||||
|
||||
// Table header
|
||||
tableContainer.appendChild(this.renderTableHeader());
|
||||
|
||||
// Table rows
|
||||
const columns = store.get('columns');
|
||||
tasks.forEach(task => {
|
||||
const column = columns.find(c => c.id === task.columnId);
|
||||
tableContainer.appendChild(this.renderTableRow(task, column));
|
||||
});
|
||||
|
||||
this.contentElement.appendChild(tableContainer);
|
||||
}
|
||||
|
||||
renderTableHeader() {
|
||||
const header = createElement('div', { className: 'list-table-header' });
|
||||
|
||||
const columnDefs = [
|
||||
{ key: 'title', label: 'Aufgabe' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'priority', label: 'Priorität' },
|
||||
{ key: 'dueDate', label: 'Fällig' },
|
||||
{ key: 'assignee', label: 'Zugewiesen' }
|
||||
];
|
||||
|
||||
columnDefs.forEach(col => {
|
||||
const isSorted = this.sortColumn === col.key;
|
||||
const span = createElement('span', {
|
||||
className: isSorted ? `sorted ${this.sortDirection}` : '',
|
||||
dataset: { sortKey: col.key }
|
||||
});
|
||||
|
||||
span.innerHTML = `
|
||||
${col.label}
|
||||
<svg viewBox="0 0 24 24"><path d="m6 9 6 6 6-6" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||
`;
|
||||
|
||||
span.addEventListener('click', () => {
|
||||
if (this.sortColumn === col.key) {
|
||||
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
this.sortColumn = col.key;
|
||||
this.sortDirection = 'asc';
|
||||
}
|
||||
if (this.sortSelect) this.sortSelect.value = this.sortColumn;
|
||||
if (this.sortDirectionBtn) {
|
||||
this.sortDirectionBtn.classList.toggle('asc', this.sortDirection === 'asc');
|
||||
}
|
||||
this.render();
|
||||
});
|
||||
|
||||
header.appendChild(span);
|
||||
});
|
||||
|
||||
return header;
|
||||
}
|
||||
|
||||
renderTableRow(task, column) {
|
||||
const row = createElement('div', {
|
||||
className: 'list-row',
|
||||
dataset: { taskId: task.id }
|
||||
});
|
||||
|
||||
// Title cell
|
||||
row.appendChild(this.renderTitleCell(task, column));
|
||||
|
||||
// Status cell
|
||||
row.appendChild(this.renderStatusCell(task, column));
|
||||
|
||||
// Priority cell
|
||||
row.appendChild(this.renderPriorityCell(task));
|
||||
|
||||
// Due date cell
|
||||
row.appendChild(this.renderDueDateCell(task));
|
||||
|
||||
// Assignee cell
|
||||
row.appendChild(this.renderAssigneeCell(task));
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
renderTitleCell(task, column) {
|
||||
const cell = createElement('div', { className: 'list-cell list-cell-title' });
|
||||
|
||||
// Color indicator
|
||||
const colorDot = createElement('span', {
|
||||
className: 'status-dot',
|
||||
style: { backgroundColor: column?.color || '#6366F1' }
|
||||
});
|
||||
cell.appendChild(colorDot);
|
||||
|
||||
// Title text (clickable to open task)
|
||||
const titleSpan = createElement('span', {
|
||||
dataset: { action: 'open-task', taskId: task.id }
|
||||
}, [escapeHtml(task.title)]);
|
||||
|
||||
cell.appendChild(titleSpan);
|
||||
|
||||
return cell;
|
||||
}
|
||||
|
||||
renderStatusCell(task, column) {
|
||||
const columns = store.get('columns');
|
||||
const cell = createElement('div', { className: 'list-cell list-cell-status list-cell-editable' });
|
||||
|
||||
// Status dot
|
||||
const dot = createElement('span', {
|
||||
className: 'status-dot',
|
||||
style: { backgroundColor: column?.color || '#6366F1' }
|
||||
});
|
||||
cell.appendChild(dot);
|
||||
|
||||
// Status dropdown
|
||||
const select = createElement('select', {
|
||||
dataset: { field: 'columnId', taskId: task.id }
|
||||
});
|
||||
|
||||
columns.forEach(col => {
|
||||
const option = createElement('option', {
|
||||
value: col.id,
|
||||
selected: col.id === task.columnId
|
||||
}, [col.name]);
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
cell.appendChild(select);
|
||||
|
||||
return cell;
|
||||
}
|
||||
|
||||
renderPriorityCell(task) {
|
||||
const cell = createElement('div', {
|
||||
className: `list-cell list-cell-priority ${task.priority || 'medium'} list-cell-editable`
|
||||
});
|
||||
|
||||
const select = createElement('select', {
|
||||
dataset: { field: 'priority', taskId: task.id }
|
||||
});
|
||||
|
||||
const priorities = [
|
||||
{ value: 'high', label: 'Hoch' },
|
||||
{ value: 'medium', label: 'Mittel' },
|
||||
{ value: 'low', label: 'Niedrig' }
|
||||
];
|
||||
|
||||
priorities.forEach(p => {
|
||||
const option = createElement('option', {
|
||||
value: p.value,
|
||||
selected: p.value === (task.priority || 'medium')
|
||||
}, [p.label]);
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
cell.appendChild(select);
|
||||
|
||||
return cell;
|
||||
}
|
||||
|
||||
renderDueDateCell(task) {
|
||||
const status = getDueDateStatus(task.dueDate);
|
||||
let className = 'list-cell list-cell-date list-cell-editable';
|
||||
|
||||
if (status === 'overdue') className += ' overdue';
|
||||
else if (status === 'today') className += ' today';
|
||||
|
||||
const cell = createElement('div', { className });
|
||||
|
||||
const input = createElement('input', {
|
||||
type: 'date',
|
||||
value: task.dueDate ? this.formatDateForInput(task.dueDate) : '',
|
||||
dataset: { field: 'dueDate', taskId: task.id }
|
||||
});
|
||||
|
||||
cell.appendChild(input);
|
||||
|
||||
return cell;
|
||||
}
|
||||
|
||||
renderAssigneeCell(task) {
|
||||
const users = store.get('users');
|
||||
const cell = createElement('div', { className: 'list-cell list-cell-assignee list-cell-editable' });
|
||||
|
||||
const assignedUser = users.find(u => u.id === task.assignedTo);
|
||||
|
||||
// Avatar
|
||||
if (assignedUser) {
|
||||
const avatar = createElement('div', {
|
||||
className: 'avatar',
|
||||
style: { backgroundColor: assignedUser.color || '#6366F1' }
|
||||
}, [getInitials(assignedUser.displayName)]);
|
||||
cell.appendChild(avatar);
|
||||
}
|
||||
|
||||
// User dropdown
|
||||
const select = createElement('select', {
|
||||
dataset: { field: 'assignedTo', taskId: task.id }
|
||||
});
|
||||
|
||||
// Empty option
|
||||
const emptyOption = createElement('option', { value: '' }, ['Nicht zugewiesen']);
|
||||
select.appendChild(emptyOption);
|
||||
|
||||
users.forEach(user => {
|
||||
const option = createElement('option', {
|
||||
value: user.id,
|
||||
selected: user.id === task.assignedTo
|
||||
}, [user.displayName]);
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
cell.appendChild(select);
|
||||
|
||||
return cell;
|
||||
}
|
||||
|
||||
// =====================
|
||||
// EVENT HANDLERS
|
||||
// =====================
|
||||
|
||||
handleContentClick(e) {
|
||||
const target = e.target.closest('[data-action]');
|
||||
if (!target) return;
|
||||
|
||||
const action = target.dataset.action;
|
||||
const taskId = target.dataset.taskId;
|
||||
|
||||
if (action === 'open-task' && taskId) {
|
||||
this.openTask(parseInt(taskId));
|
||||
}
|
||||
}
|
||||
|
||||
handleContentChange(e) {
|
||||
const target = e.target;
|
||||
const field = target.dataset.field;
|
||||
const taskId = target.dataset.taskId;
|
||||
|
||||
if (field && taskId) {
|
||||
this.updateTaskField(parseInt(taskId), field, target.value);
|
||||
}
|
||||
}
|
||||
|
||||
handleDoubleClick(e) {
|
||||
const titleCell = e.target.closest('.list-cell-title span[data-action="open-task"]');
|
||||
if (titleCell) {
|
||||
const taskId = titleCell.dataset.taskId;
|
||||
if (taskId) {
|
||||
this.startInlineEdit(parseInt(taskId), titleCell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toggleGroup(columnId) {
|
||||
if (this.collapsedGroups.has(columnId)) {
|
||||
this.collapsedGroups.delete(columnId);
|
||||
} else {
|
||||
this.collapsedGroups.add(columnId);
|
||||
}
|
||||
|
||||
// Update DOM without full re-render
|
||||
const header = this.contentElement.querySelector(`.list-group-header[data-column-id="${columnId}"]`);
|
||||
const content = header?.nextElementSibling;
|
||||
|
||||
if (header && content) {
|
||||
header.classList.toggle('collapsed');
|
||||
content.classList.toggle('collapsed');
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// INLINE EDITING
|
||||
// =====================
|
||||
|
||||
startInlineEdit(taskId, element) {
|
||||
if (this.editingCell) {
|
||||
this.cancelInlineEdit();
|
||||
}
|
||||
|
||||
const task = store.get('tasks').find(t => t.id === taskId);
|
||||
if (!task) return;
|
||||
|
||||
this.editingCell = { taskId, element, originalValue: task.title };
|
||||
|
||||
const input = createElement('input', {
|
||||
type: 'text',
|
||||
className: 'list-inline-input',
|
||||
value: task.title
|
||||
});
|
||||
|
||||
input.addEventListener('blur', () => this.finishInlineEdit());
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.finishInlineEdit();
|
||||
} else if (e.key === 'Escape') {
|
||||
this.cancelInlineEdit();
|
||||
}
|
||||
});
|
||||
|
||||
element.textContent = '';
|
||||
element.appendChild(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
|
||||
async finishInlineEdit() {
|
||||
if (!this.editingCell) return;
|
||||
|
||||
const { taskId, element } = this.editingCell;
|
||||
const input = element.querySelector('input');
|
||||
const newValue = input?.value?.trim();
|
||||
|
||||
if (newValue && newValue !== this.editingCell.originalValue) {
|
||||
await this.updateTaskField(taskId, 'title', newValue);
|
||||
}
|
||||
|
||||
this.editingCell = null;
|
||||
this.render();
|
||||
}
|
||||
|
||||
cancelInlineEdit() {
|
||||
if (!this.editingCell) return;
|
||||
|
||||
const { element, originalValue } = this.editingCell;
|
||||
element.textContent = originalValue;
|
||||
this.editingCell = null;
|
||||
}
|
||||
|
||||
// =====================
|
||||
// API OPERATIONS
|
||||
// =====================
|
||||
|
||||
async updateTaskField(taskId, field, value) {
|
||||
const projectId = store.get('currentProjectId');
|
||||
if (!projectId) return;
|
||||
|
||||
const task = store.get('tasks').find(t => t.id === taskId);
|
||||
if (!task) return;
|
||||
|
||||
// Prepare update data
|
||||
let updateData = {};
|
||||
|
||||
if (field === 'columnId') {
|
||||
updateData.columnId = parseInt(value);
|
||||
} else if (field === 'assignedTo') {
|
||||
updateData.assignedTo = value ? parseInt(value) : null;
|
||||
} else if (field === 'dueDate') {
|
||||
updateData.dueDate = value || null;
|
||||
} else if (field === 'priority') {
|
||||
updateData.priority = value;
|
||||
} else if (field === 'title') {
|
||||
updateData.title = value;
|
||||
}
|
||||
|
||||
// Optimistic update
|
||||
const tasks = store.get('tasks').map(t => {
|
||||
if (t.id === taskId) {
|
||||
return { ...t, ...updateData };
|
||||
}
|
||||
return t;
|
||||
});
|
||||
store.set('tasks', tasks);
|
||||
|
||||
try {
|
||||
await api.updateTask(projectId, taskId, updateData);
|
||||
|
||||
// Dispatch refresh event
|
||||
window.dispatchEvent(new CustomEvent('app:refresh'));
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Aktualisieren der Aufgabe:', error);
|
||||
|
||||
// Rollback on error
|
||||
const originalTasks = store.get('tasks').map(t => {
|
||||
if (t.id === taskId) {
|
||||
return task;
|
||||
}
|
||||
return t;
|
||||
});
|
||||
store.set('tasks', originalTasks);
|
||||
|
||||
// Show error notification
|
||||
window.dispatchEvent(new CustomEvent('toast:show', {
|
||||
detail: {
|
||||
type: 'error',
|
||||
message: 'Fehler beim Speichern der Änderung'
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
openTask(taskId) {
|
||||
const task = store.get('tasks').find(t => t.id === taskId);
|
||||
if (task) {
|
||||
window.dispatchEvent(new CustomEvent('task:edit', { detail: { task } }));
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// UTILITIES
|
||||
// =====================
|
||||
|
||||
formatDateForInput(dateString) {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
// Use local date formatting (NOT toISOString!)
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export singleton instance
|
||||
const listViewManager = new ListViewManager();
|
||||
export default listViewManager;
|
||||
452
frontend/js/notifications.js
Normale Datei
452
frontend/js/notifications.js
Normale Datei
@ -0,0 +1,452 @@
|
||||
/**
|
||||
* TASKMATE - Notification Manager
|
||||
* ================================
|
||||
* Frontend-Verwaltung für das Benachrichtigungssystem
|
||||
*/
|
||||
|
||||
import api from './api.js';
|
||||
import store from './store.js';
|
||||
|
||||
class NotificationManager {
|
||||
constructor() {
|
||||
this.notifications = [];
|
||||
this.unreadCount = 0;
|
||||
this.isDropdownOpen = false;
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialisierung
|
||||
*/
|
||||
async init() {
|
||||
if (this.initialized) return;
|
||||
|
||||
this.bindElements();
|
||||
this.bindEvents();
|
||||
await this.loadNotifications();
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* DOM-Elemente binden
|
||||
*/
|
||||
bindElements() {
|
||||
this.bellBtn = document.getElementById('notification-btn');
|
||||
this.badge = document.getElementById('notification-badge');
|
||||
this.dropdown = document.getElementById('notification-dropdown');
|
||||
this.list = document.getElementById('notification-list');
|
||||
this.emptyState = document.getElementById('notification-empty');
|
||||
this.markAllBtn = document.getElementById('btn-mark-all-read');
|
||||
this.bellContainer = document.getElementById('notification-bell');
|
||||
}
|
||||
|
||||
/**
|
||||
* Event-Listener binden
|
||||
*/
|
||||
bindEvents() {
|
||||
// Toggle Dropdown
|
||||
this.bellBtn?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggleDropdown();
|
||||
});
|
||||
|
||||
// Klick außerhalb schließt Dropdown
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!this.bellContainer?.contains(e.target)) {
|
||||
this.closeDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
// Alle als gelesen markieren
|
||||
this.markAllBtn?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.markAllAsRead();
|
||||
});
|
||||
|
||||
// Klicks in der Liste
|
||||
this.list?.addEventListener('click', (e) => {
|
||||
const deleteBtn = e.target.closest('.notification-delete');
|
||||
if (deleteBtn) {
|
||||
e.stopPropagation();
|
||||
const id = parseInt(deleteBtn.dataset.delete);
|
||||
this.deleteNotification(id);
|
||||
return;
|
||||
}
|
||||
|
||||
const item = e.target.closest('.notification-item');
|
||||
if (item) {
|
||||
this.handleItemClick(item);
|
||||
}
|
||||
});
|
||||
|
||||
// WebSocket Events
|
||||
window.addEventListener('notification:new', (e) => {
|
||||
this.addNotification(e.detail.notification);
|
||||
});
|
||||
|
||||
window.addEventListener('notification:count', (e) => {
|
||||
this.updateBadge(e.detail.count);
|
||||
});
|
||||
|
||||
window.addEventListener('notification:deleted', (e) => {
|
||||
this.removeNotification(e.detail.notificationId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Benachrichtigungen vom Server laden
|
||||
*/
|
||||
async loadNotifications() {
|
||||
try {
|
||||
const data = await api.getNotifications();
|
||||
this.notifications = data.notifications || [];
|
||||
this.unreadCount = data.unreadCount || 0;
|
||||
this.render();
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Benachrichtigungen:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alles rendern
|
||||
*/
|
||||
render() {
|
||||
this.updateBadge(this.unreadCount);
|
||||
this.renderList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Badge aktualisieren
|
||||
*/
|
||||
updateBadge(count) {
|
||||
this.unreadCount = count;
|
||||
|
||||
if (count > 0) {
|
||||
this.badge.textContent = count > 99 ? '99+' : count;
|
||||
this.badge.classList.remove('hidden');
|
||||
this.bellContainer?.classList.add('has-notifications');
|
||||
} else {
|
||||
this.badge.classList.add('hidden');
|
||||
this.bellContainer?.classList.remove('has-notifications');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste rendern
|
||||
*/
|
||||
renderList() {
|
||||
if (!this.notifications || this.notifications.length === 0) {
|
||||
this.list?.classList.add('hidden');
|
||||
this.emptyState?.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
this.list?.classList.remove('hidden');
|
||||
this.emptyState?.classList.add('hidden');
|
||||
|
||||
this.list.innerHTML = this.notifications.map(n => this.renderItem(n)).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Einzelnes Item rendern
|
||||
*/
|
||||
renderItem(notification) {
|
||||
const timeAgo = this.formatTimeAgo(notification.createdAt);
|
||||
const iconClass = this.getIconClass(notification.type);
|
||||
const icon = this.getIcon(notification.type);
|
||||
|
||||
return `
|
||||
<div class="notification-item ${notification.isRead ? '' : 'unread'} ${notification.isPersistent ? 'persistent' : ''}"
|
||||
data-id="${notification.id}"
|
||||
data-task-id="${notification.taskId || ''}"
|
||||
data-proposal-id="${notification.proposalId || ''}">
|
||||
<div class="notification-type-icon ${iconClass}">
|
||||
${icon}
|
||||
</div>
|
||||
<div class="notification-content">
|
||||
<div class="notification-title">${this.escapeHtml(notification.title)}</div>
|
||||
${notification.message ? `<div class="notification-message">${this.escapeHtml(notification.message)}</div>` : ''}
|
||||
<div class="notification-time">${timeAgo}</div>
|
||||
</div>
|
||||
${!notification.isPersistent ? `
|
||||
<div class="notification-actions">
|
||||
<button class="notification-delete" title="Löschen" data-delete="${notification.id}">
|
||||
<svg viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon-Klasse basierend auf Typ
|
||||
*/
|
||||
getIconClass(type) {
|
||||
if (type.startsWith('task:assigned') || type.startsWith('task:unassigned') || type.startsWith('task:due')) {
|
||||
return 'task';
|
||||
}
|
||||
if (type.startsWith('task:completed')) {
|
||||
return 'completed';
|
||||
}
|
||||
if (type.startsWith('task:priority')) {
|
||||
return 'priority';
|
||||
}
|
||||
if (type.startsWith('comment:mention')) {
|
||||
return 'mention';
|
||||
}
|
||||
if (type.startsWith('comment:')) {
|
||||
return 'comment';
|
||||
}
|
||||
if (type.startsWith('approval:')) {
|
||||
return 'approval';
|
||||
}
|
||||
return 'task';
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon SVG basierend auf Typ
|
||||
*/
|
||||
getIcon(type) {
|
||||
if (type.startsWith('task:assigned')) {
|
||||
return '<svg viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" stroke="currentColor" stroke-width="2" fill="none"/><circle cx="8.5" cy="7" r="4" stroke="currentColor" stroke-width="2" fill="none"/><path d="M20 8v6M23 11h-6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
|
||||
}
|
||||
if (type.startsWith('task:unassigned')) {
|
||||
return '<svg viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" stroke="currentColor" stroke-width="2" fill="none"/><circle cx="8.5" cy="7" r="4" stroke="currentColor" stroke-width="2" fill="none"/><path d="M23 11h-6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
|
||||
}
|
||||
if (type.startsWith('task:completed')) {
|
||||
return '<svg viewBox="0 0 24 24"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" stroke="currentColor" stroke-width="2" fill="none"/><path d="M22 4 12 14.01l-3-3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||
}
|
||||
if (type.startsWith('task:due')) {
|
||||
return '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none"/><path d="M12 6v6l4 2" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
|
||||
}
|
||||
if (type.startsWith('task:priority')) {
|
||||
return '<svg viewBox="0 0 24 24"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" stroke="currentColor" stroke-width="2" fill="none"/><path d="M12 9v4M12 17h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
|
||||
}
|
||||
if (type.startsWith('comment:mention')) {
|
||||
return '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="2" fill="none"/><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.92 7.94" stroke="currentColor" stroke-width="2" fill="none"/></svg>';
|
||||
}
|
||||
if (type.startsWith('comment:')) {
|
||||
return '<svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" stroke="currentColor" stroke-width="2" fill="none"/></svg>';
|
||||
}
|
||||
if (type.startsWith('approval:pending')) {
|
||||
return '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none"/><path d="M12 8v4M12 16h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
|
||||
}
|
||||
if (type.startsWith('approval:granted')) {
|
||||
return '<svg viewBox="0 0 24 24"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" stroke="currentColor" stroke-width="2" fill="none"/><path d="M22 4 12 14.01l-3-3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||
}
|
||||
if (type.startsWith('approval:rejected')) {
|
||||
return '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none"/><path d="M15 9l-6 6M9 9l6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
|
||||
}
|
||||
// Default
|
||||
return '<svg viewBox="0 0 24 24"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" stroke="currentColor" stroke-width="2" fill="none"/></svg>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeit-Formatierung
|
||||
*/
|
||||
formatTimeAgo(dateString) {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'Gerade eben';
|
||||
if (diffMins < 60) return `Vor ${diffMins} Minute${diffMins === 1 ? '' : 'n'}`;
|
||||
if (diffHours < 24) return `Vor ${diffHours} Stunde${diffHours === 1 ? '' : 'n'}`;
|
||||
if (diffDays < 7) return `Vor ${diffDays} Tag${diffDays === 1 ? '' : 'en'}`;
|
||||
|
||||
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML escapen
|
||||
*/
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown öffnen/schließen
|
||||
*/
|
||||
toggleDropdown() {
|
||||
if (this.isDropdownOpen) {
|
||||
this.closeDropdown();
|
||||
} else {
|
||||
this.openDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
openDropdown() {
|
||||
this.dropdown?.classList.remove('hidden');
|
||||
this.isDropdownOpen = true;
|
||||
// Ungelesene als gelesen markieren wenn Dropdown geöffnet
|
||||
this.markVisibleAsRead();
|
||||
}
|
||||
|
||||
closeDropdown() {
|
||||
this.dropdown?.classList.add('hidden');
|
||||
this.isDropdownOpen = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sichtbare Benachrichtigungen als gelesen markieren
|
||||
*/
|
||||
async markVisibleAsRead() {
|
||||
const unreadItems = this.notifications.filter(n => !n.isRead && !n.isPersistent);
|
||||
|
||||
for (const notification of unreadItems) {
|
||||
try {
|
||||
await api.markNotificationRead(notification.id);
|
||||
notification.isRead = true;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Markieren als gelesen:', error);
|
||||
}
|
||||
}
|
||||
|
||||
this.renderList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle als gelesen markieren
|
||||
*/
|
||||
async markAllAsRead() {
|
||||
try {
|
||||
await api.markAllNotificationsRead();
|
||||
this.notifications.forEach(n => {
|
||||
if (!n.isPersistent) n.isRead = true;
|
||||
});
|
||||
this.unreadCount = this.notifications.filter(n => !n.isRead).length;
|
||||
this.updateBadge(this.unreadCount);
|
||||
this.renderList();
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Markieren aller als gelesen:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Benachrichtigung löschen
|
||||
*/
|
||||
async deleteNotification(id) {
|
||||
try {
|
||||
const result = await api.deleteNotification(id);
|
||||
this.notifications = this.notifications.filter(n => n.id !== id);
|
||||
this.unreadCount = result.unreadCount;
|
||||
this.updateBadge(this.unreadCount);
|
||||
this.renderList();
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Löschen der Benachrichtigung:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Neue Benachrichtigung hinzufügen
|
||||
*/
|
||||
addNotification(notification) {
|
||||
// Am Anfang der Liste hinzufügen
|
||||
this.notifications.unshift(notification);
|
||||
this.unreadCount++;
|
||||
this.updateBadge(this.unreadCount);
|
||||
this.renderList();
|
||||
|
||||
// Toast anzeigen wenn Dropdown geschlossen
|
||||
if (!this.isDropdownOpen) {
|
||||
this.showToast(notification.title, notification.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Benachrichtigung entfernen (WebSocket)
|
||||
*/
|
||||
removeNotification(id) {
|
||||
this.notifications = this.notifications.filter(n => n.id !== id);
|
||||
this.renderList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toast-Benachrichtigung anzeigen
|
||||
*/
|
||||
showToast(title, message) {
|
||||
// Einfache Toast-Implementation
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'notification-toast';
|
||||
toast.innerHTML = `
|
||||
<strong>${this.escapeHtml(title)}</strong>
|
||||
${message ? `<p>${this.escapeHtml(message)}</p>` : ''}
|
||||
`;
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
padding: 16px 20px;
|
||||
background: var(--bg-card, #fff);
|
||||
border: 1px solid var(--border-default, #e2e8f0);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.15);
|
||||
z-index: 9999;
|
||||
max-width: 320px;
|
||||
animation: slideIn 0.3s ease;
|
||||
`;
|
||||
|
||||
// Animation
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// Nach 4 Sekunden entfernen
|
||||
setTimeout(() => {
|
||||
toast.style.animation = 'slideIn 0.3s ease reverse';
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
style.remove();
|
||||
}, 300);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item-Klick behandeln
|
||||
*/
|
||||
handleItemClick(item) {
|
||||
const taskId = item.dataset.taskId;
|
||||
const proposalId = item.dataset.proposalId;
|
||||
|
||||
if (taskId) {
|
||||
// Zur Aufgabe navigieren
|
||||
this.closeDropdown();
|
||||
window.dispatchEvent(new CustomEvent('notification:open-task', { detail: { taskId: parseInt(taskId) } }));
|
||||
} else if (proposalId) {
|
||||
// Zum Genehmigung-Tab wechseln
|
||||
this.closeDropdown();
|
||||
window.dispatchEvent(new CustomEvent('notification:open-proposal', { detail: { proposalId: parseInt(proposalId) } }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset
|
||||
*/
|
||||
reset() {
|
||||
this.notifications = [];
|
||||
this.unreadCount = 0;
|
||||
this.isDropdownOpen = false;
|
||||
this.closeDropdown();
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton-Instanz
|
||||
const notificationManager = new NotificationManager();
|
||||
|
||||
export { notificationManager };
|
||||
export default notificationManager;
|
||||
501
frontend/js/offline.js
Normale Datei
501
frontend/js/offline.js
Normale Datei
@ -0,0 +1,501 @@
|
||||
/**
|
||||
* TASKMATE - Offline Module
|
||||
* ==========================
|
||||
* IndexedDB storage and offline support
|
||||
*/
|
||||
|
||||
import store from './store.js';
|
||||
import api from './api.js';
|
||||
|
||||
const DB_NAME = 'TaskMateDB';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
class OfflineManager {
|
||||
constructor() {
|
||||
this.db = null;
|
||||
this.isReady = false;
|
||||
this.pendingSync = [];
|
||||
}
|
||||
|
||||
// Initialize IndexedDB
|
||||
async init() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = () => {
|
||||
console.error('[Offline] Failed to open database');
|
||||
reject(request.error);
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
this.isReady = true;
|
||||
console.log('[Offline] Database ready');
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = event.target.result;
|
||||
|
||||
// Projects store
|
||||
if (!db.objectStoreNames.contains('projects')) {
|
||||
const projectStore = db.createObjectStore('projects', { keyPath: 'id' });
|
||||
projectStore.createIndex('name', 'name', { unique: false });
|
||||
}
|
||||
|
||||
// Columns store
|
||||
if (!db.objectStoreNames.contains('columns')) {
|
||||
const columnStore = db.createObjectStore('columns', { keyPath: 'id' });
|
||||
columnStore.createIndex('project_id', 'project_id', { unique: false });
|
||||
columnStore.createIndex('position', 'position', { unique: false });
|
||||
}
|
||||
|
||||
// Tasks store
|
||||
if (!db.objectStoreNames.contains('tasks')) {
|
||||
const taskStore = db.createObjectStore('tasks', { keyPath: 'id' });
|
||||
taskStore.createIndex('project_id', 'project_id', { unique: false });
|
||||
taskStore.createIndex('column_id', 'column_id', { unique: false });
|
||||
taskStore.createIndex('assignee_id', 'assignee_id', { unique: false });
|
||||
taskStore.createIndex('due_date', 'due_date', { unique: false });
|
||||
}
|
||||
|
||||
// Labels store
|
||||
if (!db.objectStoreNames.contains('labels')) {
|
||||
const labelStore = db.createObjectStore('labels', { keyPath: 'id' });
|
||||
labelStore.createIndex('project_id', 'project_id', { unique: false });
|
||||
}
|
||||
|
||||
// Pending operations store (for offline sync)
|
||||
if (!db.objectStoreNames.contains('pending_operations')) {
|
||||
const pendingStore = db.createObjectStore('pending_operations', {
|
||||
keyPath: 'id',
|
||||
autoIncrement: true
|
||||
});
|
||||
pendingStore.createIndex('timestamp', 'timestamp', { unique: false });
|
||||
pendingStore.createIndex('type', 'type', { unique: false });
|
||||
}
|
||||
|
||||
// Cache metadata store
|
||||
if (!db.objectStoreNames.contains('cache_meta')) {
|
||||
db.createObjectStore('cache_meta', { keyPath: 'key' });
|
||||
}
|
||||
|
||||
console.log('[Offline] Database schema created');
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Generic CRUD operations
|
||||
async put(storeName, data) {
|
||||
if (!this.db) return;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction(storeName, 'readwrite');
|
||||
const store = transaction.objectStore(storeName);
|
||||
const request = store.put(data);
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async get(storeName, key) {
|
||||
if (!this.db) return null;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction(storeName, 'readonly');
|
||||
const store = transaction.objectStore(storeName);
|
||||
const request = store.get(key);
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async getAll(storeName, indexName = null, query = null) {
|
||||
if (!this.db) return [];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction(storeName, 'readonly');
|
||||
const store = transaction.objectStore(storeName);
|
||||
const target = indexName ? store.index(indexName) : store;
|
||||
const request = query ? target.getAll(query) : target.getAll();
|
||||
|
||||
request.onsuccess = () => resolve(request.result || []);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async delete(storeName, key) {
|
||||
if (!this.db) return;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction(storeName, 'readwrite');
|
||||
const store = transaction.objectStore(storeName);
|
||||
const request = store.delete(key);
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async clear(storeName) {
|
||||
if (!this.db) return;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction(storeName, 'readwrite');
|
||||
const store = transaction.objectStore(storeName);
|
||||
const request = store.clear();
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// =====================
|
||||
// DATA CACHING
|
||||
// =====================
|
||||
|
||||
async cacheProjects(projects) {
|
||||
await this.clear('projects');
|
||||
for (const project of projects) {
|
||||
await this.put('projects', project);
|
||||
}
|
||||
await this.setCacheMeta('projects', Date.now());
|
||||
}
|
||||
|
||||
async getCachedProjects() {
|
||||
return this.getAll('projects');
|
||||
}
|
||||
|
||||
async cacheColumns(projectId, columns) {
|
||||
// Clear existing columns for project
|
||||
const existing = await this.getAll('columns', 'project_id', projectId);
|
||||
for (const col of existing) {
|
||||
await this.delete('columns', col.id);
|
||||
}
|
||||
|
||||
// Store new columns
|
||||
for (const column of columns) {
|
||||
await this.put('columns', { ...column, project_id: projectId });
|
||||
}
|
||||
await this.setCacheMeta(`columns_${projectId}`, Date.now());
|
||||
}
|
||||
|
||||
async getCachedColumns(projectId) {
|
||||
return this.getAll('columns', 'project_id', projectId);
|
||||
}
|
||||
|
||||
async cacheTasks(projectId, tasks) {
|
||||
// Clear existing tasks for project
|
||||
const existing = await this.getAll('tasks', 'project_id', projectId);
|
||||
for (const task of existing) {
|
||||
await this.delete('tasks', task.id);
|
||||
}
|
||||
|
||||
// Store new tasks
|
||||
for (const task of tasks) {
|
||||
await this.put('tasks', { ...task, project_id: projectId });
|
||||
}
|
||||
await this.setCacheMeta(`tasks_${projectId}`, Date.now());
|
||||
}
|
||||
|
||||
async getCachedTasks(projectId) {
|
||||
return this.getAll('tasks', 'project_id', projectId);
|
||||
}
|
||||
|
||||
async cacheLabels(projectId, labels) {
|
||||
const existing = await this.getAll('labels', 'project_id', projectId);
|
||||
for (const label of existing) {
|
||||
await this.delete('labels', label.id);
|
||||
}
|
||||
|
||||
for (const label of labels) {
|
||||
await this.put('labels', { ...label, project_id: projectId });
|
||||
}
|
||||
await this.setCacheMeta(`labels_${projectId}`, Date.now());
|
||||
}
|
||||
|
||||
async getCachedLabels(projectId) {
|
||||
return this.getAll('labels', 'project_id', projectId);
|
||||
}
|
||||
|
||||
// Cache metadata
|
||||
async setCacheMeta(key, timestamp) {
|
||||
await this.put('cache_meta', { key, timestamp });
|
||||
}
|
||||
|
||||
async getCacheMeta(key) {
|
||||
return this.get('cache_meta', key);
|
||||
}
|
||||
|
||||
async isCacheValid(key, maxAge = 5 * 60 * 1000) {
|
||||
const meta = await this.getCacheMeta(key);
|
||||
if (!meta) return false;
|
||||
|
||||
return Date.now() - meta.timestamp < maxAge;
|
||||
}
|
||||
|
||||
// =====================
|
||||
// OFFLINE OPERATIONS
|
||||
// =====================
|
||||
|
||||
async queueOperation(operation) {
|
||||
const pendingOp = {
|
||||
...operation,
|
||||
timestamp: Date.now(),
|
||||
synced: false
|
||||
};
|
||||
|
||||
await this.put('pending_operations', pendingOp);
|
||||
store.setSyncStatus('offline');
|
||||
|
||||
return pendingOp;
|
||||
}
|
||||
|
||||
async getPendingOperations() {
|
||||
return this.getAll('pending_operations');
|
||||
}
|
||||
|
||||
async markOperationSynced(operationId) {
|
||||
await this.delete('pending_operations', operationId);
|
||||
}
|
||||
|
||||
async clearPendingOperations() {
|
||||
await this.clear('pending_operations');
|
||||
}
|
||||
|
||||
// Sync pending operations with server
|
||||
async syncPendingOperations() {
|
||||
if (!navigator.onLine) {
|
||||
return { success: false, reason: 'offline' };
|
||||
}
|
||||
|
||||
const operations = await this.getPendingOperations();
|
||||
|
||||
if (operations.length === 0) {
|
||||
return { success: true, synced: 0 };
|
||||
}
|
||||
|
||||
console.log(`[Offline] Syncing ${operations.length} pending operations`);
|
||||
store.setSyncStatus('syncing');
|
||||
|
||||
let syncedCount = 0;
|
||||
const errors = [];
|
||||
|
||||
// Sort by timestamp
|
||||
operations.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
for (const op of operations) {
|
||||
try {
|
||||
await this.executePendingOperation(op);
|
||||
await this.markOperationSynced(op.id);
|
||||
syncedCount++;
|
||||
} catch (error) {
|
||||
console.error(`[Offline] Failed to sync operation:`, op, error);
|
||||
errors.push({ operation: op, error: error.message });
|
||||
|
||||
// Stop on auth errors
|
||||
if (error.status === 401) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = await this.getPendingOperations();
|
||||
store.setSyncStatus(remaining.length > 0 ? 'offline' : 'synced');
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
synced: syncedCount,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
async executePendingOperation(op) {
|
||||
const projectId = op.projectId;
|
||||
|
||||
switch (op.type) {
|
||||
case 'task:create':
|
||||
const newTask = await api.createTask(projectId, op.data);
|
||||
// Update local ID mapping
|
||||
if (op.tempId) {
|
||||
await this.updateTaskId(op.tempId, newTask.id);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'task:update':
|
||||
await api.updateTask(projectId, op.taskId, op.data);
|
||||
break;
|
||||
|
||||
case 'task:delete':
|
||||
await api.deleteTask(projectId, op.taskId);
|
||||
break;
|
||||
|
||||
case 'task:move':
|
||||
await api.moveTask(projectId, op.taskId, op.columnId, op.position);
|
||||
break;
|
||||
|
||||
case 'column:create':
|
||||
const newColumn = await api.createColumn(projectId, op.data);
|
||||
if (op.tempId) {
|
||||
await this.updateColumnId(op.tempId, newColumn.id);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'column:update':
|
||||
await api.updateColumn(projectId, op.columnId, op.data);
|
||||
break;
|
||||
|
||||
case 'column:delete':
|
||||
await api.deleteColumn(projectId, op.columnId);
|
||||
break;
|
||||
|
||||
case 'subtask:create':
|
||||
await api.createSubtask(projectId, op.taskId, op.data);
|
||||
break;
|
||||
|
||||
case 'subtask:update':
|
||||
await api.updateSubtask(projectId, op.taskId, op.subtaskId, op.data);
|
||||
break;
|
||||
|
||||
case 'comment:create':
|
||||
await api.createComment(projectId, op.taskId, op.data);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`[Offline] Unknown operation type: ${op.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
async updateTaskId(tempId, realId) {
|
||||
// Update in cache
|
||||
const task = await this.get('tasks', tempId);
|
||||
if (task) {
|
||||
await this.delete('tasks', tempId);
|
||||
await this.put('tasks', { ...task, id: realId });
|
||||
}
|
||||
|
||||
// Update pending operations that reference this task
|
||||
const pending = await this.getPendingOperations();
|
||||
for (const op of pending) {
|
||||
if (op.taskId === tempId) {
|
||||
await this.put('pending_operations', { ...op, taskId: realId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async updateColumnId(tempId, realId) {
|
||||
const column = await this.get('columns', tempId);
|
||||
if (column) {
|
||||
await this.delete('columns', tempId);
|
||||
await this.put('columns', { ...column, id: realId });
|
||||
}
|
||||
|
||||
// Update tasks in this column
|
||||
const tasks = await this.getAll('tasks');
|
||||
for (const task of tasks) {
|
||||
if (task.column_id === tempId) {
|
||||
await this.put('tasks', { ...task, column_id: realId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// OFFLINE DATA LOADING
|
||||
// =====================
|
||||
|
||||
async loadOfflineData() {
|
||||
const projectId = store.get('currentProjectId');
|
||||
if (!projectId) return;
|
||||
|
||||
console.log('[Offline] Loading cached data');
|
||||
|
||||
try {
|
||||
// Load from cache
|
||||
const [columns, tasks, labels] = await Promise.all([
|
||||
this.getCachedColumns(projectId),
|
||||
this.getCachedTasks(projectId),
|
||||
this.getCachedLabels(projectId)
|
||||
]);
|
||||
|
||||
store.setColumns(columns);
|
||||
store.setTasks(tasks);
|
||||
store.setLabels(labels);
|
||||
|
||||
console.log('[Offline] Loaded cached data:', {
|
||||
columns: columns.length,
|
||||
tasks: tasks.length,
|
||||
labels: labels.length
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Offline] Failed to load cached data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// HELPERS
|
||||
// =====================
|
||||
|
||||
hasPendingOperations() {
|
||||
return this.getPendingOperations().then(ops => ops.length > 0);
|
||||
}
|
||||
|
||||
async getPendingOperationCount() {
|
||||
const ops = await this.getPendingOperations();
|
||||
return ops.length;
|
||||
}
|
||||
|
||||
// Clear all cached data
|
||||
async clearAllData() {
|
||||
const stores = ['projects', 'columns', 'tasks', 'labels', 'pending_operations', 'cache_meta'];
|
||||
|
||||
for (const storeName of stores) {
|
||||
await this.clear(storeName);
|
||||
}
|
||||
|
||||
console.log('[Offline] Cleared all cached data');
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const offlineManager = new OfflineManager();
|
||||
|
||||
// Initialize on load
|
||||
offlineManager.init().catch(console.error);
|
||||
|
||||
// Listen for online event to sync
|
||||
window.addEventListener('online', async () => {
|
||||
console.log('[Offline] Back online, starting sync');
|
||||
await offlineManager.syncPendingOperations();
|
||||
});
|
||||
|
||||
// Subscribe to store changes to cache data
|
||||
store.subscribe('columns', async (columns) => {
|
||||
const projectId = store.get('currentProjectId');
|
||||
if (projectId && columns.length > 0) {
|
||||
await offlineManager.cacheColumns(projectId, columns);
|
||||
}
|
||||
});
|
||||
|
||||
store.subscribe('tasks', async (tasks) => {
|
||||
const projectId = store.get('currentProjectId');
|
||||
if (projectId && tasks.length > 0) {
|
||||
await offlineManager.cacheTasks(projectId, tasks);
|
||||
}
|
||||
});
|
||||
|
||||
store.subscribe('labels', async (labels) => {
|
||||
const projectId = store.get('currentProjectId');
|
||||
if (projectId && labels.length > 0) {
|
||||
await offlineManager.cacheLabels(projectId, labels);
|
||||
}
|
||||
});
|
||||
|
||||
store.subscribe('projects', async (projects) => {
|
||||
if (projects.length > 0) {
|
||||
await offlineManager.cacheProjects(projects);
|
||||
}
|
||||
});
|
||||
|
||||
export default offlineManager;
|
||||
469
frontend/js/proposals.js
Normale Datei
469
frontend/js/proposals.js
Normale Datei
@ -0,0 +1,469 @@
|
||||
/**
|
||||
* TASKMATE - Proposals Manager
|
||||
* ============================
|
||||
* Genehmigungen (projektbezogen)
|
||||
*/
|
||||
|
||||
import api from './api.js';
|
||||
import { $, $$ } from './utils.js';
|
||||
import authManager from './auth.js';
|
||||
import store from './store.js';
|
||||
|
||||
class ProposalsManager {
|
||||
constructor() {
|
||||
this.proposals = [];
|
||||
this.currentSort = 'date';
|
||||
this.showArchived = false;
|
||||
this.searchQuery = '';
|
||||
this.allTasks = [];
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
async init() {
|
||||
console.log('[Proposals] init() called, initialized:', this.initialized);
|
||||
|
||||
if (this.initialized) {
|
||||
await this.loadProposals();
|
||||
return;
|
||||
}
|
||||
|
||||
// DOM Elements - erst bei init() laden
|
||||
this.proposalsView = $('#view-proposals');
|
||||
this.proposalsList = $('#proposals-list');
|
||||
this.proposalsEmpty = $('#proposals-empty');
|
||||
this.sortSelect = $('#proposals-sort');
|
||||
this.newProposalBtn = $('#btn-new-proposal');
|
||||
this.archiveBtn = $('#btn-show-proposals-archive');
|
||||
this.proposalsTitle = $('#proposals-title');
|
||||
|
||||
console.log('[Proposals] newProposalBtn found:', !!this.newProposalBtn);
|
||||
|
||||
// Modal Elements
|
||||
this.proposalModal = $('#proposal-modal');
|
||||
this.proposalForm = $('#proposal-form');
|
||||
this.proposalTitle = $('#proposal-title');
|
||||
this.proposalDescription = $('#proposal-description');
|
||||
this.proposalTask = $('#proposal-task');
|
||||
|
||||
this.bindEvents();
|
||||
this.initialized = true;
|
||||
console.log('[Proposals] Initialization complete');
|
||||
|
||||
await this.loadProposals();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
console.log('[Proposals] bindEvents() called');
|
||||
|
||||
// Sort Change
|
||||
this.sortSelect?.addEventListener('change', () => {
|
||||
this.currentSort = this.sortSelect.value;
|
||||
this.loadProposals();
|
||||
});
|
||||
|
||||
// Archive Toggle Button
|
||||
this.archiveBtn?.addEventListener('click', () => {
|
||||
this.showArchived = !this.showArchived;
|
||||
this.updateArchiveButton();
|
||||
this.loadProposals();
|
||||
});
|
||||
|
||||
// New Proposal Button
|
||||
if (this.newProposalBtn) {
|
||||
console.log('[Proposals] Binding click event to newProposalBtn');
|
||||
this.newProposalBtn.addEventListener('click', () => {
|
||||
console.log('[Proposals] Button clicked!');
|
||||
this.openNewProposalModal();
|
||||
});
|
||||
} else {
|
||||
console.error('[Proposals] newProposalBtn not found!');
|
||||
}
|
||||
|
||||
// Proposal Form Submit
|
||||
this.proposalForm?.addEventListener('submit', (e) => this.handleProposalSubmit(e));
|
||||
|
||||
// Modal close buttons
|
||||
this.proposalModal?.querySelectorAll('[data-close-modal]').forEach(btn => {
|
||||
btn.addEventListener('click', () => this.closeModal());
|
||||
});
|
||||
}
|
||||
|
||||
updateArchiveButton() {
|
||||
if (this.archiveBtn) {
|
||||
this.archiveBtn.textContent = this.showArchived ? 'Aktive anzeigen' : 'Archiv anzeigen';
|
||||
}
|
||||
if (this.proposalsTitle) {
|
||||
this.proposalsTitle.textContent = this.showArchived ? 'Archiv' : 'Genehmigungen';
|
||||
}
|
||||
}
|
||||
|
||||
resetToActiveView() {
|
||||
this.showArchived = false;
|
||||
this.searchQuery = '';
|
||||
this.updateArchiveButton();
|
||||
this.loadProposals();
|
||||
}
|
||||
|
||||
setSearchQuery(query) {
|
||||
this.searchQuery = query.toLowerCase().trim();
|
||||
this.render();
|
||||
}
|
||||
|
||||
getFilteredProposals() {
|
||||
if (!this.searchQuery) {
|
||||
return this.proposals;
|
||||
}
|
||||
return this.proposals.filter(proposal => {
|
||||
const titleMatch = proposal.title?.toLowerCase().includes(this.searchQuery);
|
||||
const descriptionMatch = proposal.description?.toLowerCase().includes(this.searchQuery);
|
||||
return titleMatch || descriptionMatch;
|
||||
});
|
||||
}
|
||||
|
||||
getCurrentProjectId() {
|
||||
// Direkt die currentProjectId aus dem Store holen
|
||||
return store.get('currentProjectId') || null;
|
||||
}
|
||||
|
||||
async loadProposals() {
|
||||
try {
|
||||
const projectId = this.getCurrentProjectId();
|
||||
this.proposals = await api.getProposals(this.currentSort, this.showArchived, projectId);
|
||||
this.render();
|
||||
} catch (error) {
|
||||
console.error('Error loading proposals:', error);
|
||||
this.showToast('Fehler beim Laden der Genehmigungen', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async loadTasks() {
|
||||
try {
|
||||
this.allTasks = await api.getAllTasks();
|
||||
this.populateTaskDropdown();
|
||||
} catch (error) {
|
||||
console.error('Error loading tasks:', error);
|
||||
}
|
||||
}
|
||||
|
||||
populateTaskDropdown() {
|
||||
if (!this.proposalTask) return;
|
||||
|
||||
const currentProjectId = this.getCurrentProjectId();
|
||||
|
||||
// Reset dropdown
|
||||
this.proposalTask.innerHTML = '<option value="">Keine Aufgabe</option>';
|
||||
|
||||
// Nur Aufgaben des aktuellen Projekts anzeigen
|
||||
const projectTasks = this.allTasks.filter(task => task.project_id === currentProjectId);
|
||||
|
||||
projectTasks.forEach(task => {
|
||||
const option = document.createElement('option');
|
||||
option.value = task.id;
|
||||
option.textContent = task.title;
|
||||
this.proposalTask.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.proposalsList) return;
|
||||
|
||||
const filteredProposals = this.getFilteredProposals();
|
||||
|
||||
if (filteredProposals.length === 0) {
|
||||
this.proposalsList.classList.add('hidden');
|
||||
this.proposalsEmpty?.classList.remove('hidden');
|
||||
// Update empty message based on search
|
||||
if (this.proposalsEmpty) {
|
||||
const h3 = this.proposalsEmpty.querySelector('h3');
|
||||
const p = this.proposalsEmpty.querySelector('p');
|
||||
if (this.searchQuery) {
|
||||
if (h3) h3.textContent = 'Keine Treffer';
|
||||
if (p) p.textContent = 'Keine Genehmigungen entsprechen der Suche.';
|
||||
} else {
|
||||
if (h3) h3.textContent = 'Keine Genehmigungen vorhanden';
|
||||
if (p) p.textContent = 'Erstellen Sie die erste Genehmigung!';
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.proposalsEmpty?.classList.add('hidden');
|
||||
this.proposalsList.classList.remove('hidden');
|
||||
|
||||
const currentUserId = authManager.getUser()?.id;
|
||||
const canApprove = authManager.hasPermission('genehmigung');
|
||||
|
||||
this.proposalsList.innerHTML = filteredProposals.map(proposal =>
|
||||
this.renderProposalCard(proposal, currentUserId, canApprove)
|
||||
).join('');
|
||||
|
||||
// Bind event listeners
|
||||
this.bindProposalEvents();
|
||||
}
|
||||
|
||||
renderProposalCard(proposal, currentUserId, canApprove) {
|
||||
const isOwn = proposal.created_by === currentUserId;
|
||||
const initial = (proposal.created_by_name || 'U').charAt(0).toUpperCase();
|
||||
const dateStr = this.formatDate(proposal.created_at);
|
||||
const isArchived = proposal.archived === 1;
|
||||
|
||||
return `
|
||||
<div class="proposal-card ${proposal.approved ? 'approved' : ''} ${isArchived ? 'archived' : ''}" data-proposal-id="${proposal.id}">
|
||||
<div class="proposal-header">
|
||||
<h3 class="proposal-title">${this.escapeHtml(proposal.title)}</h3>
|
||||
<div class="proposal-badges">
|
||||
${isArchived ? `
|
||||
<span class="proposal-archived-badge">
|
||||
<svg viewBox="0 0 24 24"><path d="M21 8v13H3V8M1 3h22v5H1zM10 12h4" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||
Archiviert
|
||||
</span>
|
||||
` : ''}
|
||||
${proposal.approved ? `
|
||||
<span class="proposal-approved-badge">
|
||||
<svg viewBox="0 0 24 24"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" stroke="currentColor" stroke-width="2" fill="none"/><path d="m22 4-10 10-3-3" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||
Genehmigt
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${proposal.description ? `
|
||||
<p class="proposal-description">${this.escapeHtml(proposal.description)}</p>
|
||||
` : ''}
|
||||
${proposal.task_title ? `
|
||||
<div class="proposal-linked-task">
|
||||
<svg viewBox="0 0 24 24"><path d="M9 11l3 3L22 4" stroke="currentColor" stroke-width="2" fill="none"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||
<span>Verknüpft mit: ${this.escapeHtml(proposal.task_title)}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="proposal-meta">
|
||||
<div class="proposal-author">
|
||||
<span class="proposal-author-avatar" style="background-color: ${proposal.created_by_color || '#888'}">
|
||||
${initial}
|
||||
</span>
|
||||
<span>${this.escapeHtml(proposal.created_by_name)}</span>
|
||||
<span class="proposal-date">${dateStr}</span>
|
||||
</div>
|
||||
<div class="proposal-actions">
|
||||
${canApprove && !isArchived ? `
|
||||
<label class="proposal-approve ${proposal.approved ? 'approved' : ''}" title="Genehmigen">
|
||||
<input type="checkbox" data-action="approve" ${proposal.approved ? 'checked' : ''}>
|
||||
<span>Genehmigt</span>
|
||||
</label>
|
||||
` : ''}
|
||||
${canApprove ? `
|
||||
<button class="proposal-archive-btn" data-action="archive" title="${isArchived ? 'Wiederherstellen' : 'Archivieren'}">
|
||||
${isArchived ? `
|
||||
<svg viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" stroke="currentColor" stroke-width="2" fill="none"/><path d="M3 3v5h5" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||
` : `
|
||||
<svg viewBox="0 0 24 24"><path d="M21 8v13H3V8M1 3h22v5H1zM10 12h4" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||
`}
|
||||
</button>
|
||||
` : ''}
|
||||
${(isOwn || canApprove) && !isArchived ? `
|
||||
<button class="proposal-delete-btn" data-action="delete" title="Löschen">
|
||||
<svg viewBox="0 0 24 24"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${proposal.approved && proposal.approved_by_name ? `
|
||||
<div class="proposal-approved-by">
|
||||
Genehmigt von ${this.escapeHtml(proposal.approved_by_name)} am ${this.formatDate(proposal.approved_at)}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
bindProposalEvents() {
|
||||
this.proposalsList?.querySelectorAll('.proposal-card').forEach(card => {
|
||||
const proposalId = parseInt(card.dataset.proposalId);
|
||||
const proposal = this.proposals.find(p => p.id === proposalId);
|
||||
|
||||
// Approve checkbox
|
||||
const approveCheckbox = card.querySelector('[data-action="approve"]');
|
||||
approveCheckbox?.addEventListener('change', (e) => this.handleApprove(proposalId, e.target.checked));
|
||||
|
||||
// Archive button
|
||||
const archiveBtn = card.querySelector('[data-action="archive"]');
|
||||
archiveBtn?.addEventListener('click', () => this.handleArchive(proposalId, proposal?.archived !== 1));
|
||||
|
||||
// Delete button
|
||||
const deleteBtn = card.querySelector('[data-action="delete"]');
|
||||
deleteBtn?.addEventListener('click', () => this.handleDelete(proposalId));
|
||||
});
|
||||
}
|
||||
|
||||
async handleApprove(proposalId, approved) {
|
||||
try {
|
||||
await api.approveProposal(proposalId, approved);
|
||||
await this.loadProposals();
|
||||
this.showToast(approved ? 'Genehmigung erteilt' : 'Genehmigung zurückgezogen', 'success');
|
||||
// Board aktualisieren, damit die Genehmigung auf der Task-Karte aktualisiert wird
|
||||
window.dispatchEvent(new CustomEvent('app:refresh'));
|
||||
} catch (error) {
|
||||
this.showToast(error.message || 'Fehler beim Genehmigen', 'error');
|
||||
// Revert checkbox
|
||||
await this.loadProposals();
|
||||
}
|
||||
}
|
||||
|
||||
async handleArchive(proposalId, archive) {
|
||||
try {
|
||||
await api.archiveProposal(proposalId, archive);
|
||||
await this.loadProposals();
|
||||
this.showToast(archive ? 'Genehmigung archiviert' : 'Genehmigung wiederhergestellt', 'success');
|
||||
// Board aktualisieren
|
||||
window.dispatchEvent(new CustomEvent('app:refresh'));
|
||||
} catch (error) {
|
||||
this.showToast(error.message || 'Fehler beim Archivieren', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async handleDelete(proposalId) {
|
||||
const proposal = this.proposals.find(p => p.id === proposalId);
|
||||
if (!proposal) return;
|
||||
|
||||
const confirmDelete = confirm(`Genehmigung "${proposal.title}" wirklich löschen?`);
|
||||
if (!confirmDelete) return;
|
||||
|
||||
try {
|
||||
await api.deleteProposal(proposalId);
|
||||
this.showToast('Genehmigung gelöscht', 'success');
|
||||
await this.loadProposals();
|
||||
// Board aktualisieren
|
||||
window.dispatchEvent(new CustomEvent('app:refresh'));
|
||||
} catch (error) {
|
||||
this.showToast(error.message || 'Fehler beim Löschen', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async openNewProposalModal() {
|
||||
console.log('[Proposals] Opening new proposal modal');
|
||||
this.proposalForm?.reset();
|
||||
await this.loadTasks();
|
||||
this.openModal();
|
||||
this.proposalTitle?.focus();
|
||||
}
|
||||
|
||||
async handleProposalSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const title = this.proposalTitle.value.trim();
|
||||
const description = this.proposalDescription.value.trim();
|
||||
const taskId = this.proposalTask?.value ? parseInt(this.proposalTask.value) : null;
|
||||
const projectId = this.getCurrentProjectId();
|
||||
|
||||
if (!title) {
|
||||
this.showToast('Bitte einen Titel eingeben', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
this.showToast('Kein Projekt ausgewählt', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.createProposal({ title, description, taskId, projectId });
|
||||
this.showToast('Genehmigung erstellt', 'success');
|
||||
this.closeModal();
|
||||
await this.loadProposals();
|
||||
// Board aktualisieren, damit die Genehmigung auf der Task-Karte erscheint
|
||||
window.dispatchEvent(new CustomEvent('app:refresh'));
|
||||
} catch (error) {
|
||||
this.showToast(error.message || 'Fehler beim Erstellen', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
openModal() {
|
||||
if (this.proposalModal) {
|
||||
this.proposalModal.classList.remove('hidden');
|
||||
this.proposalModal.classList.add('visible');
|
||||
}
|
||||
const overlay = $('#modal-overlay');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('hidden');
|
||||
overlay.classList.add('visible');
|
||||
}
|
||||
store.openModal('proposal-modal');
|
||||
}
|
||||
|
||||
closeModal() {
|
||||
if (this.proposalModal) {
|
||||
this.proposalModal.classList.remove('visible');
|
||||
this.proposalModal.classList.add('hidden');
|
||||
}
|
||||
// Only hide overlay if no other modals are open
|
||||
const openModals = store.get('openModals').filter(id => id !== 'proposal-modal');
|
||||
if (openModals.length === 0) {
|
||||
const overlay = $('#modal-overlay');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('visible');
|
||||
overlay.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
store.closeModal('proposal-modal');
|
||||
}
|
||||
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
showToast(message, type = 'info') {
|
||||
window.dispatchEvent(new CustomEvent('toast:show', {
|
||||
detail: { message, type }
|
||||
}));
|
||||
}
|
||||
|
||||
escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
show() {
|
||||
this.proposalsView?.classList.remove('hidden');
|
||||
this.proposalsView?.classList.add('active');
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.proposalsView?.classList.add('hidden');
|
||||
this.proposalsView?.classList.remove('active');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrollt zu einer Genehmigung und hebt sie hervor
|
||||
*/
|
||||
scrollToAndHighlight(proposalId) {
|
||||
const card = this.proposalsList?.querySelector(`[data-proposal-id="${proposalId}"]`);
|
||||
if (!card) {
|
||||
console.warn('[Proposals] Proposal card not found:', proposalId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Scroll to the card
|
||||
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
|
||||
// Add highlight class for animation
|
||||
card.classList.add('highlight-pulse');
|
||||
|
||||
// Remove highlight class after animation
|
||||
setTimeout(() => {
|
||||
card.classList.remove('highlight-pulse');
|
||||
}, 2500);
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const proposalsManager = new ProposalsManager();
|
||||
|
||||
export { proposalsManager };
|
||||
export default proposalsManager;
|
||||
141
frontend/js/shortcuts.js
Normale Datei
141
frontend/js/shortcuts.js
Normale Datei
@ -0,0 +1,141 @@
|
||||
/**
|
||||
* TASKMATE - Keyboard Shortcuts Module
|
||||
* =====================================
|
||||
* Global keyboard shortcuts (only non-browser-conflicting)
|
||||
*/
|
||||
|
||||
import store from './store.js';
|
||||
import { $ } from './utils.js';
|
||||
|
||||
class ShortcutsManager {
|
||||
constructor() {
|
||||
this.shortcuts = new Map();
|
||||
this.isEnabled = true;
|
||||
|
||||
this.registerDefaultShortcuts();
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
document.addEventListener('keydown', (e) => this.handleKeydown(e));
|
||||
}
|
||||
|
||||
handleKeydown(e) {
|
||||
// Don't handle shortcuts when typing in inputs
|
||||
if (this.isInputFocused()) return;
|
||||
|
||||
// Don't handle when modals are open (except escape)
|
||||
if (store.get('openModals').length > 0 && e.key !== 'Escape') return;
|
||||
|
||||
// Don't handle when disabled
|
||||
if (!this.isEnabled) return;
|
||||
|
||||
const combo = this.getKeyCombo(e);
|
||||
const handler = this.shortcuts.get(combo);
|
||||
|
||||
if (handler) {
|
||||
e.preventDefault();
|
||||
handler(e);
|
||||
}
|
||||
}
|
||||
|
||||
getKeyCombo(e) {
|
||||
const parts = [];
|
||||
|
||||
if (e.ctrlKey || e.metaKey) parts.push('Ctrl');
|
||||
if (e.altKey) parts.push('Alt');
|
||||
if (e.shiftKey) parts.push('Shift');
|
||||
|
||||
// Get the key
|
||||
let key = e.key;
|
||||
if (key === ' ') key = 'Space';
|
||||
if (key.length === 1) key = key.toUpperCase();
|
||||
|
||||
parts.push(key);
|
||||
|
||||
return parts.join('+');
|
||||
}
|
||||
|
||||
isInputFocused() {
|
||||
const activeElement = document.activeElement;
|
||||
const tagName = activeElement?.tagName.toLowerCase();
|
||||
const isContentEditable = activeElement?.contentEditable === 'true';
|
||||
|
||||
return ['input', 'textarea', 'select'].includes(tagName) || isContentEditable;
|
||||
}
|
||||
|
||||
// =====================
|
||||
// SHORTCUT REGISTRATION
|
||||
// =====================
|
||||
|
||||
register(combo, handler, description = '') {
|
||||
this.shortcuts.set(combo, handler);
|
||||
// Store description for help display
|
||||
handler.description = description;
|
||||
}
|
||||
|
||||
unregister(combo) {
|
||||
this.shortcuts.delete(combo);
|
||||
}
|
||||
|
||||
enable() {
|
||||
this.isEnabled = true;
|
||||
}
|
||||
|
||||
disable() {
|
||||
this.isEnabled = false;
|
||||
}
|
||||
|
||||
// =====================
|
||||
// DEFAULT SHORTCUTS
|
||||
// =====================
|
||||
|
||||
registerDefaultShortcuts() {
|
||||
// Only Escape - close modals (doesn't conflict with browser)
|
||||
this.register('Escape', () => this.handleEscape(), 'Schließen / Abbrechen');
|
||||
}
|
||||
|
||||
// =====================
|
||||
// SHORTCUT HANDLERS
|
||||
// =====================
|
||||
|
||||
handleEscape() {
|
||||
const openModals = store.get('openModals');
|
||||
|
||||
if (openModals.length > 0) {
|
||||
// Close the topmost modal
|
||||
const topModal = openModals[openModals.length - 1];
|
||||
window.dispatchEvent(new CustomEvent('modal:close', {
|
||||
detail: { modalId: topModal }
|
||||
}));
|
||||
} else {
|
||||
// Clear selection
|
||||
store.clearSelection();
|
||||
}
|
||||
}
|
||||
|
||||
showHelp() {
|
||||
// No longer needed - shortcuts removed
|
||||
}
|
||||
|
||||
// =====================
|
||||
// HELP DISPLAY
|
||||
// =====================
|
||||
|
||||
getShortcutsList() {
|
||||
// Minimal shortcuts that don't conflict with browser
|
||||
return {
|
||||
actions: {
|
||||
title: 'Aktionen',
|
||||
shortcuts: [
|
||||
{ combo: 'Escape', description: 'Dialog schließen / Auswahl aufheben' }
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export singleton
|
||||
const shortcutsManager = new ShortcutsManager();
|
||||
|
||||
export default shortcutsManager;
|
||||
584
frontend/js/store.js
Normale Datei
584
frontend/js/store.js
Normale Datei
@ -0,0 +1,584 @@
|
||||
/**
|
||||
* TASKMATE - State Store
|
||||
* ======================
|
||||
* Centralized state management
|
||||
*/
|
||||
|
||||
import { deepClone, deepMerge } from './utils.js';
|
||||
|
||||
class Store {
|
||||
constructor() {
|
||||
this.state = {
|
||||
// App state
|
||||
currentView: 'board',
|
||||
isOnline: navigator.onLine,
|
||||
isLoading: false,
|
||||
syncStatus: 'synced', // 'synced', 'syncing', 'offline', 'error'
|
||||
|
||||
// User state
|
||||
currentUser: null,
|
||||
users: [],
|
||||
|
||||
// Project state
|
||||
projects: [],
|
||||
currentProjectId: null,
|
||||
|
||||
// Board state
|
||||
columns: [],
|
||||
tasks: [],
|
||||
labels: [],
|
||||
|
||||
// Filters
|
||||
filters: {
|
||||
search: '',
|
||||
priority: 'all',
|
||||
assignee: 'all',
|
||||
label: 'all',
|
||||
dueDate: 'all',
|
||||
archived: false
|
||||
},
|
||||
|
||||
// Server search result IDs (bypass client filter for these)
|
||||
searchResultIds: [],
|
||||
|
||||
// Selection
|
||||
selectedTaskIds: [],
|
||||
|
||||
// Modal state
|
||||
openModals: [],
|
||||
editingTask: null,
|
||||
|
||||
// UI state
|
||||
dragState: null,
|
||||
contextMenu: null,
|
||||
|
||||
// Undo stack
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
maxUndoHistory: 50
|
||||
};
|
||||
|
||||
this.subscribers = new Map();
|
||||
this.middlewares = [];
|
||||
}
|
||||
|
||||
// Get current state
|
||||
getState() {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
// Get specific state path
|
||||
get(path) {
|
||||
return path.split('.').reduce((obj, key) => obj?.[key], this.state);
|
||||
}
|
||||
|
||||
// Update state
|
||||
setState(updates, actionType = 'SET_STATE') {
|
||||
const prevState = deepClone(this.state);
|
||||
|
||||
// Apply middlewares
|
||||
let processedUpdates = updates;
|
||||
for (const middleware of this.middlewares) {
|
||||
processedUpdates = middleware(prevState, processedUpdates, actionType);
|
||||
}
|
||||
|
||||
// Merge updates
|
||||
this.state = deepMerge(this.state, processedUpdates);
|
||||
|
||||
// Notify subscribers
|
||||
this.notifySubscribers(prevState, this.state, actionType);
|
||||
|
||||
return this.state;
|
||||
}
|
||||
|
||||
// Set specific state path
|
||||
set(path, value, actionType) {
|
||||
const keys = path.split('.');
|
||||
const updates = keys.reduceRight((acc, key) => ({ [key]: acc }), value);
|
||||
return this.setState(updates, actionType || `SET_${path.toUpperCase()}`);
|
||||
}
|
||||
|
||||
// Subscribe to state changes
|
||||
subscribe(selector, callback, immediate = false) {
|
||||
const id = Symbol();
|
||||
|
||||
this.subscribers.set(id, { selector, callback });
|
||||
|
||||
if (immediate) {
|
||||
const currentValue = typeof selector === 'function'
|
||||
? selector(this.state)
|
||||
: this.get(selector);
|
||||
callback(currentValue, currentValue);
|
||||
}
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => this.subscribers.delete(id);
|
||||
}
|
||||
|
||||
// Notify subscribers of changes
|
||||
notifySubscribers(prevState, newState, actionType) {
|
||||
this.subscribers.forEach(({ selector, callback }) => {
|
||||
const prevValue = typeof selector === 'function'
|
||||
? selector(prevState)
|
||||
: selector.split('.').reduce((obj, key) => obj?.[key], prevState);
|
||||
|
||||
const newValue = typeof selector === 'function'
|
||||
? selector(newState)
|
||||
: selector.split('.').reduce((obj, key) => obj?.[key], newState);
|
||||
|
||||
// Only call if value changed
|
||||
if (JSON.stringify(prevValue) !== JSON.stringify(newValue)) {
|
||||
callback(newValue, prevValue, actionType);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add middleware
|
||||
use(middleware) {
|
||||
this.middlewares.push(middleware);
|
||||
}
|
||||
|
||||
// Reset state
|
||||
reset() {
|
||||
this.state = {
|
||||
currentView: 'board',
|
||||
isOnline: navigator.onLine,
|
||||
isLoading: false,
|
||||
syncStatus: 'synced',
|
||||
currentUser: null,
|
||||
users: [],
|
||||
projects: [],
|
||||
currentProjectId: null,
|
||||
columns: [],
|
||||
tasks: [],
|
||||
labels: [],
|
||||
filters: {
|
||||
search: '',
|
||||
priority: 'all',
|
||||
assignee: 'all',
|
||||
label: 'all',
|
||||
dueDate: 'all',
|
||||
archived: false
|
||||
},
|
||||
searchResultIds: [],
|
||||
selectedTaskIds: [],
|
||||
openModals: [],
|
||||
editingTask: null,
|
||||
dragState: null,
|
||||
contextMenu: null,
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
maxUndoHistory: 50
|
||||
};
|
||||
}
|
||||
|
||||
// =====================
|
||||
// PROJECT ACTIONS
|
||||
// =====================
|
||||
|
||||
setProjects(projects) {
|
||||
this.setState({ projects }, 'SET_PROJECTS');
|
||||
}
|
||||
|
||||
addProject(project) {
|
||||
this.setState({
|
||||
projects: [...this.state.projects, project]
|
||||
}, 'ADD_PROJECT');
|
||||
}
|
||||
|
||||
updateProject(projectId, updates) {
|
||||
this.setState({
|
||||
projects: this.state.projects.map(p =>
|
||||
p.id === projectId ? { ...p, ...updates } : p
|
||||
)
|
||||
}, 'UPDATE_PROJECT');
|
||||
}
|
||||
|
||||
removeProject(projectId) {
|
||||
this.setState({
|
||||
projects: this.state.projects.filter(p => p.id !== projectId)
|
||||
}, 'REMOVE_PROJECT');
|
||||
}
|
||||
|
||||
setCurrentProject(projectId) {
|
||||
this.setState({ currentProjectId: projectId }, 'SET_CURRENT_PROJECT');
|
||||
localStorage.setItem('current_project_id', projectId);
|
||||
}
|
||||
|
||||
getCurrentProject() {
|
||||
return this.state.projects.find(p => p.id === this.state.currentProjectId);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// COLUMN ACTIONS
|
||||
// =====================
|
||||
|
||||
setColumns(columns) {
|
||||
this.setState({ columns }, 'SET_COLUMNS');
|
||||
}
|
||||
|
||||
addColumn(column) {
|
||||
this.setState({
|
||||
columns: [...this.state.columns, column]
|
||||
}, 'ADD_COLUMN');
|
||||
}
|
||||
|
||||
updateColumn(columnId, updates) {
|
||||
this.setState({
|
||||
columns: this.state.columns.map(c =>
|
||||
c.id === columnId ? { ...c, ...updates } : c
|
||||
)
|
||||
}, 'UPDATE_COLUMN');
|
||||
}
|
||||
|
||||
removeColumn(columnId) {
|
||||
this.setState({
|
||||
columns: this.state.columns.filter(c => c.id !== columnId),
|
||||
tasks: this.state.tasks.filter(t => t.columnId !== columnId)
|
||||
}, 'REMOVE_COLUMN');
|
||||
}
|
||||
|
||||
reorderColumns(columnIds) {
|
||||
const columnsMap = new Map(this.state.columns.map(c => [c.id, c]));
|
||||
const reordered = columnIds.map((id, index) => ({
|
||||
...columnsMap.get(id),
|
||||
position: index
|
||||
}));
|
||||
|
||||
this.setState({ columns: reordered }, 'REORDER_COLUMNS');
|
||||
}
|
||||
|
||||
// =====================
|
||||
// TASK ACTIONS
|
||||
// =====================
|
||||
|
||||
setTasks(tasks) {
|
||||
this.setState({ tasks }, 'SET_TASKS');
|
||||
}
|
||||
|
||||
addTask(task) {
|
||||
this.setState({
|
||||
tasks: [...this.state.tasks, task]
|
||||
}, 'ADD_TASK');
|
||||
}
|
||||
|
||||
updateTask(taskId, updates) {
|
||||
this.setState({
|
||||
tasks: this.state.tasks.map(t =>
|
||||
t.id === taskId ? { ...t, ...updates } : t
|
||||
)
|
||||
}, 'UPDATE_TASK');
|
||||
}
|
||||
|
||||
removeTask(taskId) {
|
||||
this.setState({
|
||||
tasks: this.state.tasks.filter(t => t.id !== taskId),
|
||||
selectedTaskIds: this.state.selectedTaskIds.filter(id => id !== taskId)
|
||||
}, 'REMOVE_TASK');
|
||||
}
|
||||
|
||||
moveTask(taskId, columnId, position) {
|
||||
const tasks = [...this.state.tasks];
|
||||
const taskIndex = tasks.findIndex(t => t.id === taskId);
|
||||
|
||||
if (taskIndex === -1) return;
|
||||
|
||||
const task = { ...tasks[taskIndex], columnId: columnId, position };
|
||||
tasks.splice(taskIndex, 1);
|
||||
|
||||
// Find insert position
|
||||
const columnTasks = tasks.filter(t => t.columnId === columnId);
|
||||
const insertIndex = tasks.findIndex(t => t.columnId === columnId && t.position >= position);
|
||||
|
||||
if (insertIndex === -1) {
|
||||
tasks.push(task);
|
||||
} else {
|
||||
tasks.splice(insertIndex, 0, task);
|
||||
}
|
||||
|
||||
// Recalculate positions
|
||||
let pos = 0;
|
||||
tasks.forEach(t => {
|
||||
if (t.columnId === columnId) {
|
||||
t.position = pos++;
|
||||
}
|
||||
});
|
||||
|
||||
this.setState({ tasks }, 'MOVE_TASK');
|
||||
}
|
||||
|
||||
getTaskById(taskId) {
|
||||
return this.state.tasks.find(t => t.id === taskId);
|
||||
}
|
||||
|
||||
getTasksByColumn(columnId) {
|
||||
// Priority order: high (0) > medium (1) > low (2)
|
||||
const priorityOrder = { high: 0, medium: 1, low: 2 };
|
||||
|
||||
return this.state.tasks
|
||||
.filter(t => t.columnId === columnId && !t.archived)
|
||||
.sort((a, b) => {
|
||||
// 1. First by position (manual drag&drop sorting)
|
||||
const posA = a.position ?? 999999;
|
||||
const posB = b.position ?? 999999;
|
||||
if (posA !== posB) return posA - posB;
|
||||
|
||||
// 2. Then by priority (high > medium > low)
|
||||
const priA = priorityOrder[a.priority] ?? 1;
|
||||
const priB = priorityOrder[b.priority] ?? 1;
|
||||
if (priA !== priB) return priA - priB;
|
||||
|
||||
// 3. Then by creation date (older first)
|
||||
const dateA = new Date(a.createdAt || 0).getTime();
|
||||
const dateB = new Date(b.createdAt || 0).getTime();
|
||||
return dateA - dateB;
|
||||
});
|
||||
}
|
||||
|
||||
// =====================
|
||||
// LABEL ACTIONS
|
||||
// =====================
|
||||
|
||||
setLabels(labels) {
|
||||
this.setState({ labels }, 'SET_LABELS');
|
||||
}
|
||||
|
||||
addLabel(label) {
|
||||
this.setState({
|
||||
labels: [...this.state.labels, label]
|
||||
}, 'ADD_LABEL');
|
||||
}
|
||||
|
||||
updateLabel(labelId, updates) {
|
||||
this.setState({
|
||||
labels: this.state.labels.map(l =>
|
||||
l.id === labelId ? { ...l, ...updates } : l
|
||||
)
|
||||
}, 'UPDATE_LABEL');
|
||||
}
|
||||
|
||||
removeLabel(labelId) {
|
||||
this.setState({
|
||||
labels: this.state.labels.filter(l => l.id !== labelId)
|
||||
}, 'REMOVE_LABEL');
|
||||
}
|
||||
|
||||
// =====================
|
||||
// FILTER ACTIONS
|
||||
// =====================
|
||||
|
||||
setFilter(key, value) {
|
||||
this.setState({
|
||||
filters: { ...this.state.filters, [key]: value }
|
||||
}, 'SET_FILTER');
|
||||
}
|
||||
|
||||
setFilters(filters) {
|
||||
this.setState({
|
||||
filters: { ...this.state.filters, ...filters }
|
||||
}, 'SET_FILTERS');
|
||||
}
|
||||
|
||||
resetFilters() {
|
||||
this.setState({
|
||||
filters: {
|
||||
search: '',
|
||||
priority: 'all',
|
||||
assignee: 'all',
|
||||
label: 'all',
|
||||
dueDate: 'all',
|
||||
archived: false
|
||||
}
|
||||
}, 'RESET_FILTERS');
|
||||
}
|
||||
|
||||
// =====================
|
||||
// SELECTION ACTIONS
|
||||
// =====================
|
||||
|
||||
selectTask(taskId, multi = false) {
|
||||
if (multi) {
|
||||
const selected = this.state.selectedTaskIds.includes(taskId)
|
||||
? this.state.selectedTaskIds.filter(id => id !== taskId)
|
||||
: [...this.state.selectedTaskIds, taskId];
|
||||
|
||||
this.setState({ selectedTaskIds: selected }, 'SELECT_TASK');
|
||||
} else {
|
||||
this.setState({ selectedTaskIds: [taskId] }, 'SELECT_TASK');
|
||||
}
|
||||
}
|
||||
|
||||
deselectTask(taskId) {
|
||||
this.setState({
|
||||
selectedTaskIds: this.state.selectedTaskIds.filter(id => id !== taskId)
|
||||
}, 'DESELECT_TASK');
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
this.setState({ selectedTaskIds: [] }, 'CLEAR_SELECTION');
|
||||
}
|
||||
|
||||
selectAllInColumn(columnId) {
|
||||
const taskIds = this.getTasksByColumn(columnId).map(t => t.id);
|
||||
this.setState({ selectedTaskIds: taskIds }, 'SELECT_ALL_IN_COLUMN');
|
||||
}
|
||||
|
||||
// =====================
|
||||
// UI STATE ACTIONS
|
||||
// =====================
|
||||
|
||||
setCurrentView(view) {
|
||||
this.setState({ currentView: view }, 'SET_VIEW');
|
||||
}
|
||||
|
||||
setLoading(isLoading) {
|
||||
this.setState({ isLoading }, 'SET_LOADING');
|
||||
}
|
||||
|
||||
setOnline(isOnline) {
|
||||
this.setState({
|
||||
isOnline,
|
||||
syncStatus: isOnline ? 'synced' : 'offline'
|
||||
}, 'SET_ONLINE');
|
||||
}
|
||||
|
||||
setSyncStatus(status) {
|
||||
this.setState({ syncStatus: status }, 'SET_SYNC_STATUS');
|
||||
}
|
||||
|
||||
setDragState(dragState) {
|
||||
this.setState({ dragState }, 'SET_DRAG_STATE');
|
||||
}
|
||||
|
||||
setEditingTask(task) {
|
||||
this.setState({ editingTask: task }, 'SET_EDITING_TASK');
|
||||
}
|
||||
|
||||
// =====================
|
||||
// MODAL ACTIONS
|
||||
// =====================
|
||||
|
||||
openModal(modalId) {
|
||||
if (!this.state.openModals.includes(modalId)) {
|
||||
this.setState({
|
||||
openModals: [...this.state.openModals, modalId]
|
||||
}, 'OPEN_MODAL');
|
||||
}
|
||||
}
|
||||
|
||||
closeModal(modalId) {
|
||||
this.setState({
|
||||
openModals: this.state.openModals.filter(id => id !== modalId)
|
||||
}, 'CLOSE_MODAL');
|
||||
}
|
||||
|
||||
closeAllModals() {
|
||||
this.setState({ openModals: [] }, 'CLOSE_ALL_MODALS');
|
||||
}
|
||||
|
||||
isModalOpen(modalId) {
|
||||
return this.state.openModals.includes(modalId);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// UNDO/REDO ACTIONS
|
||||
// =====================
|
||||
|
||||
pushUndo(action) {
|
||||
const undoStack = [...this.state.undoStack, action];
|
||||
|
||||
// Limit stack size
|
||||
if (undoStack.length > this.state.maxUndoHistory) {
|
||||
undoStack.shift();
|
||||
}
|
||||
|
||||
this.setState({
|
||||
undoStack,
|
||||
redoStack: [] // Clear redo stack on new action
|
||||
}, 'PUSH_UNDO');
|
||||
}
|
||||
|
||||
popUndo() {
|
||||
if (this.state.undoStack.length === 0) return null;
|
||||
|
||||
const undoStack = [...this.state.undoStack];
|
||||
const action = undoStack.pop();
|
||||
|
||||
this.setState({
|
||||
undoStack,
|
||||
redoStack: [...this.state.redoStack, action]
|
||||
}, 'POP_UNDO');
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
popRedo() {
|
||||
if (this.state.redoStack.length === 0) return null;
|
||||
|
||||
const redoStack = [...this.state.redoStack];
|
||||
const action = redoStack.pop();
|
||||
|
||||
this.setState({
|
||||
redoStack,
|
||||
undoStack: [...this.state.undoStack, action]
|
||||
}, 'POP_REDO');
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
canUndo() {
|
||||
return this.state.undoStack.length > 0;
|
||||
}
|
||||
|
||||
canRedo() {
|
||||
return this.state.redoStack.length > 0;
|
||||
}
|
||||
|
||||
// =====================
|
||||
// USER ACTIONS
|
||||
// =====================
|
||||
|
||||
setCurrentUser(user) {
|
||||
this.setState({ currentUser: user }, 'SET_CURRENT_USER');
|
||||
}
|
||||
|
||||
setUsers(users) {
|
||||
this.setState({ users }, 'SET_USERS');
|
||||
}
|
||||
|
||||
getUserById(userId) {
|
||||
return this.state.users.find(u => u.id === userId);
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const store = new Store();
|
||||
|
||||
// Debug middleware (only in development)
|
||||
if (window.location.hostname === 'localhost') {
|
||||
store.use((prevState, updates, actionType) => {
|
||||
console.log(`[Store] ${actionType}:`, updates);
|
||||
return updates;
|
||||
});
|
||||
}
|
||||
|
||||
// Persistence middleware for filters (EXCLUDING search to prevent "hanging" search)
|
||||
store.subscribe('filters', (filters) => {
|
||||
// Save filters WITHOUT search - search should not persist across sessions
|
||||
const { search, ...filtersToSave } = filters;
|
||||
localStorage.setItem('task_filters', JSON.stringify(filtersToSave));
|
||||
});
|
||||
|
||||
// Load persisted filters
|
||||
const savedFilters = localStorage.getItem('task_filters');
|
||||
if (savedFilters) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedFilters);
|
||||
// Ensure search is always empty on load
|
||||
store.setFilters({ ...parsed, search: '' });
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
export default store;
|
||||
577
frontend/js/sync.js
Normale Datei
577
frontend/js/sync.js
Normale Datei
@ -0,0 +1,577 @@
|
||||
/**
|
||||
* TASKMATE - Real-time Sync Module
|
||||
* =================================
|
||||
* WebSocket synchronization with Socket.io
|
||||
*/
|
||||
|
||||
import store from './store.js';
|
||||
import api from './api.js';
|
||||
|
||||
class SyncManager {
|
||||
constructor() {
|
||||
this.socket = null;
|
||||
this.isConnected = false;
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = 10;
|
||||
this.reconnectDelay = 1000;
|
||||
this.pendingOperations = [];
|
||||
this.operationQueue = [];
|
||||
this.isProcessingQueue = false;
|
||||
}
|
||||
|
||||
// Initialize Socket.io connection
|
||||
async connect() {
|
||||
if (this.socket?.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = api.getToken();
|
||||
if (!token) {
|
||||
console.log('[Sync] No auth token, skipping connection');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Socket.io is loaded globally via script tag
|
||||
if (typeof io === 'undefined') {
|
||||
throw new Error('Socket.io not loaded');
|
||||
}
|
||||
|
||||
this.socket = io({
|
||||
auth: { token },
|
||||
reconnection: true,
|
||||
reconnectionAttempts: this.maxReconnectAttempts,
|
||||
reconnectionDelay: this.reconnectDelay,
|
||||
reconnectionDelayMax: 10000,
|
||||
timeout: 20000
|
||||
});
|
||||
|
||||
this.setupEventListeners();
|
||||
} catch (error) {
|
||||
console.error('[Sync] Failed to connect:', error);
|
||||
store.setSyncStatus('error');
|
||||
}
|
||||
}
|
||||
|
||||
// Setup socket event listeners
|
||||
setupEventListeners() {
|
||||
if (!this.socket) return;
|
||||
|
||||
// Connection events
|
||||
this.socket.on('connect', () => {
|
||||
console.log('[Sync] Connected');
|
||||
this.isConnected = true;
|
||||
this.reconnectAttempts = 0;
|
||||
store.setSyncStatus('synced');
|
||||
|
||||
// Join current project room
|
||||
const projectId = store.get('currentProjectId');
|
||||
if (projectId) {
|
||||
this.joinProject(projectId);
|
||||
}
|
||||
|
||||
// Process pending operations
|
||||
this.processPendingOperations();
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', (reason) => {
|
||||
console.log('[Sync] Disconnected:', reason);
|
||||
this.isConnected = false;
|
||||
|
||||
if (reason === 'io server disconnect') {
|
||||
// Server initiated disconnect, try reconnecting
|
||||
this.socket.connect();
|
||||
}
|
||||
|
||||
store.setSyncStatus('offline');
|
||||
});
|
||||
|
||||
this.socket.on('connect_error', (error) => {
|
||||
console.error('[Sync] Connection error:', error);
|
||||
this.reconnectAttempts++;
|
||||
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
store.setSyncStatus('error');
|
||||
} else {
|
||||
store.setSyncStatus('offline');
|
||||
}
|
||||
});
|
||||
|
||||
// Auth error
|
||||
this.socket.on('error', (error) => {
|
||||
console.error('[Sync] Socket error:', error);
|
||||
|
||||
if (error.type === 'auth') {
|
||||
// Auth failed, logout
|
||||
window.dispatchEvent(new CustomEvent('auth:logout'));
|
||||
}
|
||||
});
|
||||
|
||||
// Data sync events
|
||||
this.socket.on('project:updated', (data) => {
|
||||
this.handleProjectUpdated(data);
|
||||
});
|
||||
|
||||
this.socket.on('column:created', (data) => {
|
||||
this.handleColumnCreated(data);
|
||||
});
|
||||
|
||||
this.socket.on('column:updated', (data) => {
|
||||
this.handleColumnUpdated(data);
|
||||
});
|
||||
|
||||
this.socket.on('column:deleted', (data) => {
|
||||
this.handleColumnDeleted(data);
|
||||
});
|
||||
|
||||
this.socket.on('column:reordered', (data) => {
|
||||
this.handleColumnsReordered(data);
|
||||
});
|
||||
|
||||
this.socket.on('task:created', (data) => {
|
||||
this.handleTaskCreated(data);
|
||||
});
|
||||
|
||||
this.socket.on('task:updated', (data) => {
|
||||
this.handleTaskUpdated(data);
|
||||
});
|
||||
|
||||
this.socket.on('task:deleted', (data) => {
|
||||
this.handleTaskDeleted(data);
|
||||
});
|
||||
|
||||
this.socket.on('task:moved', (data) => {
|
||||
this.handleTaskMoved(data);
|
||||
});
|
||||
|
||||
this.socket.on('label:created', (data) => {
|
||||
this.handleLabelCreated(data);
|
||||
});
|
||||
|
||||
this.socket.on('label:updated', (data) => {
|
||||
this.handleLabelUpdated(data);
|
||||
});
|
||||
|
||||
this.socket.on('label:deleted', (data) => {
|
||||
this.handleLabelDeleted(data);
|
||||
});
|
||||
|
||||
this.socket.on('comment:created', (data) => {
|
||||
this.handleCommentCreated(data);
|
||||
});
|
||||
|
||||
this.socket.on('subtask:updated', (data) => {
|
||||
this.handleSubtaskUpdated(data);
|
||||
});
|
||||
|
||||
// User presence events
|
||||
this.socket.on('user:joined', (data) => {
|
||||
this.handleUserJoined(data);
|
||||
});
|
||||
|
||||
this.socket.on('user:left', (data) => {
|
||||
this.handleUserLeft(data);
|
||||
});
|
||||
|
||||
this.socket.on('user:typing', (data) => {
|
||||
this.handleUserTyping(data);
|
||||
});
|
||||
|
||||
// Notification events
|
||||
this.socket.on('notification:new', (data) => {
|
||||
this.handleNotificationNew(data);
|
||||
});
|
||||
|
||||
this.socket.on('notification:count', (data) => {
|
||||
this.handleNotificationCount(data);
|
||||
});
|
||||
|
||||
this.socket.on('notification:deleted', (data) => {
|
||||
this.handleNotificationDeleted(data);
|
||||
});
|
||||
}
|
||||
|
||||
// Disconnect socket
|
||||
disconnect() {
|
||||
if (this.socket) {
|
||||
this.socket.disconnect();
|
||||
this.socket = null;
|
||||
this.isConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Join project room
|
||||
joinProject(projectId) {
|
||||
if (!this.socket?.connected) return;
|
||||
|
||||
this.socket.emit('project:join', { projectId });
|
||||
console.log('[Sync] Joined project:', projectId);
|
||||
}
|
||||
|
||||
// Leave project room
|
||||
leaveProject(projectId) {
|
||||
if (!this.socket?.connected) return;
|
||||
|
||||
this.socket.emit('project:leave', { projectId });
|
||||
console.log('[Sync] Left project:', projectId);
|
||||
}
|
||||
|
||||
// Emit event with queueing for offline support
|
||||
emit(event, data) {
|
||||
if (this.socket?.connected) {
|
||||
this.socket.emit(event, data);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Queue for later
|
||||
this.pendingOperations.push({ event, data, timestamp: Date.now() });
|
||||
store.setSyncStatus('offline');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Process pending operations when reconnected
|
||||
async processPendingOperations() {
|
||||
if (!this.socket?.connected || this.pendingOperations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Sync] Processing ${this.pendingOperations.length} pending operations`);
|
||||
store.setSyncStatus('syncing');
|
||||
|
||||
const operations = [...this.pendingOperations];
|
||||
this.pendingOperations = [];
|
||||
|
||||
for (const op of operations) {
|
||||
try {
|
||||
this.socket.emit(op.event, op.data);
|
||||
await this.delay(100); // Small delay between operations
|
||||
} catch (error) {
|
||||
console.error('[Sync] Failed to process operation:', error);
|
||||
// Re-queue failed operation
|
||||
this.pendingOperations.push(op);
|
||||
}
|
||||
}
|
||||
|
||||
store.setSyncStatus(this.pendingOperations.length > 0 ? 'offline' : 'synced');
|
||||
}
|
||||
|
||||
// Helper delay function
|
||||
delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// =====================
|
||||
// EVENT HANDLERS
|
||||
// =====================
|
||||
|
||||
handleProjectUpdated(data) {
|
||||
const { project, userId } = data;
|
||||
|
||||
// Ignore own updates
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.updateProject(project.id, project);
|
||||
|
||||
this.showNotification('Projekt aktualisiert', `${data.username} hat das Projekt aktualisiert`);
|
||||
}
|
||||
|
||||
handleColumnCreated(data) {
|
||||
const { column, userId } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.addColumn(column);
|
||||
|
||||
this.showNotification('Neue Spalte', `${data.username} hat eine Spalte erstellt`);
|
||||
}
|
||||
|
||||
handleColumnUpdated(data) {
|
||||
const { column, userId } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.updateColumn(column.id, column);
|
||||
}
|
||||
|
||||
handleColumnDeleted(data) {
|
||||
const { columnId, userId } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.removeColumn(columnId);
|
||||
}
|
||||
|
||||
handleColumnsReordered(data) {
|
||||
const { columnIds, userId } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.reorderColumns(columnIds);
|
||||
}
|
||||
|
||||
handleTaskCreated(data) {
|
||||
const { task, userId, username } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.addTask(task);
|
||||
|
||||
this.showNotification('Neue Aufgabe', `${username} hat "${task.title}" erstellt`);
|
||||
}
|
||||
|
||||
handleTaskUpdated(data) {
|
||||
const { task, userId, changes } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.updateTask(task.id, task);
|
||||
|
||||
// Show notification for significant changes
|
||||
if (changes.includes('status') || changes.includes('assignee')) {
|
||||
this.showNotification('Aufgabe aktualisiert', `"${task.title}" wurde aktualisiert`);
|
||||
}
|
||||
}
|
||||
|
||||
handleTaskDeleted(data) {
|
||||
const { taskId, userId, taskTitle } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.removeTask(taskId);
|
||||
|
||||
this.showNotification('Aufgabe gelöscht', `"${taskTitle}" wurde gelöscht`);
|
||||
}
|
||||
|
||||
handleTaskMoved(data) {
|
||||
const { taskId, columnId, position, userId } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.moveTask(taskId, columnId, position);
|
||||
}
|
||||
|
||||
handleLabelCreated(data) {
|
||||
const { label, userId } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.addLabel(label);
|
||||
}
|
||||
|
||||
handleLabelUpdated(data) {
|
||||
const { label, userId } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.updateLabel(label.id, label);
|
||||
}
|
||||
|
||||
handleLabelDeleted(data) {
|
||||
const { labelId, userId } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.removeLabel(labelId);
|
||||
}
|
||||
|
||||
handleCommentCreated(data) {
|
||||
const { taskId, comment, userId, username } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
// Update task comment count
|
||||
const task = store.getTaskById(taskId);
|
||||
if (task) {
|
||||
store.updateTask(taskId, {
|
||||
commentCount: (task.commentCount || 0) + 1
|
||||
});
|
||||
}
|
||||
|
||||
// Check for mention
|
||||
const currentUser = store.get('currentUser');
|
||||
if (comment.content.includes(`@${currentUser?.username}`)) {
|
||||
this.showNotification(
|
||||
'Du wurdest erwähnt',
|
||||
`${username} hat dich in "${task?.title}" erwähnt`,
|
||||
'mention'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
handleSubtaskUpdated(data) {
|
||||
const { taskId, subtask, userId } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
// Trigger task refresh if viewing this task
|
||||
const editingTask = store.get('editingTask');
|
||||
if (editingTask?.id === taskId) {
|
||||
window.dispatchEvent(new CustomEvent('task:refresh', {
|
||||
detail: { taskId }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
handleUserJoined(data) {
|
||||
const { userId, username } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
this.showNotification('Benutzer online', `${username} ist jetzt online`, 'info');
|
||||
}
|
||||
|
||||
handleUserLeft(data) {
|
||||
const { userId, username } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
// Optional: show notification
|
||||
}
|
||||
|
||||
handleUserTyping(data) {
|
||||
const { userId, taskId, isTyping } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
window.dispatchEvent(new CustomEvent('user:typing', {
|
||||
detail: { userId, taskId, isTyping }
|
||||
}));
|
||||
}
|
||||
|
||||
handleNotificationNew(data) {
|
||||
const { notification } = data;
|
||||
window.dispatchEvent(new CustomEvent('notification:new', {
|
||||
detail: { notification }
|
||||
}));
|
||||
}
|
||||
|
||||
handleNotificationCount(data) {
|
||||
const { count } = data;
|
||||
window.dispatchEvent(new CustomEvent('notification:count', {
|
||||
detail: { count }
|
||||
}));
|
||||
}
|
||||
|
||||
handleNotificationDeleted(data) {
|
||||
const { notificationId } = data;
|
||||
window.dispatchEvent(new CustomEvent('notification:deleted', {
|
||||
detail: { notificationId }
|
||||
}));
|
||||
}
|
||||
|
||||
// =====================
|
||||
// OUTGOING EVENTS
|
||||
// =====================
|
||||
|
||||
notifyTaskCreated(task) {
|
||||
this.emit('task:create', {
|
||||
task,
|
||||
projectId: store.get('currentProjectId')
|
||||
});
|
||||
}
|
||||
|
||||
notifyTaskUpdated(task, changes) {
|
||||
this.emit('task:update', {
|
||||
task,
|
||||
changes,
|
||||
projectId: store.get('currentProjectId')
|
||||
});
|
||||
}
|
||||
|
||||
notifyTaskDeleted(taskId, taskTitle) {
|
||||
this.emit('task:delete', {
|
||||
taskId,
|
||||
taskTitle,
|
||||
projectId: store.get('currentProjectId')
|
||||
});
|
||||
}
|
||||
|
||||
notifyTaskMoved(taskId, columnId, position) {
|
||||
this.emit('task:move', {
|
||||
taskId,
|
||||
columnId,
|
||||
position,
|
||||
projectId: store.get('currentProjectId')
|
||||
});
|
||||
}
|
||||
|
||||
notifyColumnCreated(column) {
|
||||
this.emit('column:create', {
|
||||
column,
|
||||
projectId: store.get('currentProjectId')
|
||||
});
|
||||
}
|
||||
|
||||
notifyColumnUpdated(column) {
|
||||
this.emit('column:update', {
|
||||
column,
|
||||
projectId: store.get('currentProjectId')
|
||||
});
|
||||
}
|
||||
|
||||
notifyColumnDeleted(columnId) {
|
||||
this.emit('column:delete', {
|
||||
columnId,
|
||||
projectId: store.get('currentProjectId')
|
||||
});
|
||||
}
|
||||
|
||||
notifyColumnsReordered(columnIds) {
|
||||
this.emit('column:reorder', {
|
||||
columnIds,
|
||||
projectId: store.get('currentProjectId')
|
||||
});
|
||||
}
|
||||
|
||||
notifyTyping(taskId, isTyping) {
|
||||
this.emit('user:typing', {
|
||||
taskId,
|
||||
isTyping,
|
||||
projectId: store.get('currentProjectId')
|
||||
});
|
||||
}
|
||||
|
||||
// =====================
|
||||
// NOTIFICATIONS
|
||||
// =====================
|
||||
|
||||
showNotification(title, message, type = 'info') {
|
||||
window.dispatchEvent(new CustomEvent('toast:show', {
|
||||
detail: { title, message, type }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const syncManager = new SyncManager();
|
||||
|
||||
// Listen for auth events
|
||||
window.addEventListener('auth:login', () => {
|
||||
syncManager.connect();
|
||||
});
|
||||
|
||||
window.addEventListener('auth:logout', () => {
|
||||
syncManager.disconnect();
|
||||
});
|
||||
|
||||
// Listen for project changes
|
||||
store.subscribe('currentProjectId', (newProjectId, oldProjectId) => {
|
||||
if (oldProjectId) {
|
||||
syncManager.leaveProject(oldProjectId);
|
||||
}
|
||||
if (newProjectId) {
|
||||
syncManager.joinProject(newProjectId);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for online/offline events
|
||||
window.addEventListener('online', () => {
|
||||
store.setOnline(true);
|
||||
syncManager.connect();
|
||||
});
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
store.setOnline(false);
|
||||
});
|
||||
|
||||
export default syncManager;
|
||||
1465
frontend/js/task-modal.js
Normale Datei
1465
frontend/js/task-modal.js
Normale Datei
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
325
frontend/js/tour.js
Normale Datei
325
frontend/js/tour.js
Normale Datei
@ -0,0 +1,325 @@
|
||||
/**
|
||||
* 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;
|
||||
332
frontend/js/undo.js
Normale Datei
332
frontend/js/undo.js
Normale Datei
@ -0,0 +1,332 @@
|
||||
/**
|
||||
* TASKMATE - Undo/Redo Module
|
||||
* ===========================
|
||||
* Undo and redo functionality
|
||||
*/
|
||||
|
||||
import store from './store.js';
|
||||
import api from './api.js';
|
||||
|
||||
class UndoManager {
|
||||
constructor() {
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
window.addEventListener('undo:perform', () => this.undo());
|
||||
window.addEventListener('redo:perform', () => this.redo());
|
||||
}
|
||||
|
||||
// =====================
|
||||
// UNDO/REDO
|
||||
// =====================
|
||||
|
||||
async undo() {
|
||||
if (!store.canUndo()) {
|
||||
this.showInfo('Nichts zum Rückgängigmachen');
|
||||
return;
|
||||
}
|
||||
|
||||
const action = store.popUndo();
|
||||
if (!action) return;
|
||||
|
||||
try {
|
||||
await this.executeUndo(action);
|
||||
this.showSuccess('Rückgängig gemacht');
|
||||
} catch (error) {
|
||||
console.error('Undo failed:', error);
|
||||
this.showError('Rückgängig fehlgeschlagen');
|
||||
// Re-push to undo stack
|
||||
store.pushUndo(action);
|
||||
}
|
||||
}
|
||||
|
||||
async redo() {
|
||||
if (!store.canRedo()) {
|
||||
this.showInfo('Nichts zum Wiederholen');
|
||||
return;
|
||||
}
|
||||
|
||||
const action = store.popRedo();
|
||||
if (!action) return;
|
||||
|
||||
try {
|
||||
await this.executeRedo(action);
|
||||
this.showSuccess('Wiederholt');
|
||||
} catch (error) {
|
||||
console.error('Redo failed:', error);
|
||||
this.showError('Wiederholen fehlgeschlagen');
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// ACTION EXECUTION
|
||||
// =====================
|
||||
|
||||
async executeUndo(action) {
|
||||
const projectId = store.get('currentProjectId');
|
||||
|
||||
switch (action.type) {
|
||||
case 'DELETE_TASK':
|
||||
// Restore deleted task
|
||||
const restoredTask = await api.createTask(projectId, {
|
||||
...action.task,
|
||||
id: undefined // Let server assign new ID
|
||||
});
|
||||
store.addTask(restoredTask);
|
||||
break;
|
||||
|
||||
case 'CREATE_TASK':
|
||||
// Delete created task
|
||||
await api.deleteTask(projectId, action.taskId);
|
||||
store.removeTask(action.taskId);
|
||||
break;
|
||||
|
||||
case 'UPDATE_TASK':
|
||||
// Restore previous values
|
||||
await api.updateTask(projectId, action.taskId, action.previousData);
|
||||
store.updateTask(action.taskId, action.previousData);
|
||||
break;
|
||||
|
||||
case 'MOVE_TASK':
|
||||
// Move back to original position
|
||||
await api.moveTask(projectId, action.taskId, action.fromColumnId, action.fromPosition);
|
||||
store.moveTask(action.taskId, action.fromColumnId, action.fromPosition);
|
||||
break;
|
||||
|
||||
case 'DELETE_COLUMN':
|
||||
// Restore deleted column (without tasks - they're gone)
|
||||
const restoredColumn = await api.createColumn(projectId, action.column);
|
||||
store.addColumn(restoredColumn);
|
||||
break;
|
||||
|
||||
case 'CREATE_COLUMN':
|
||||
// Delete created column
|
||||
await api.deleteColumn(projectId, action.columnId);
|
||||
store.removeColumn(action.columnId);
|
||||
break;
|
||||
|
||||
case 'UPDATE_COLUMN':
|
||||
// Restore previous values
|
||||
await api.updateColumn(projectId, action.columnId, action.previousData);
|
||||
store.updateColumn(action.columnId, action.previousData);
|
||||
break;
|
||||
|
||||
case 'BULK_DELETE':
|
||||
// Restore all deleted tasks
|
||||
for (const task of action.tasks) {
|
||||
const restored = await api.createTask(projectId, {
|
||||
...task,
|
||||
id: undefined
|
||||
});
|
||||
store.addTask(restored);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'BULK_MOVE':
|
||||
// Move all tasks back
|
||||
for (const item of action.items) {
|
||||
await api.moveTask(projectId, item.taskId, item.fromColumnId, item.fromPosition);
|
||||
store.moveTask(item.taskId, item.fromColumnId, item.fromPosition);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'BULK_UPDATE':
|
||||
// Restore all previous values
|
||||
for (const item of action.items) {
|
||||
await api.updateTask(projectId, item.taskId, item.previousData);
|
||||
store.updateTask(item.taskId, item.previousData);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('Unknown undo action type:', action.type);
|
||||
}
|
||||
}
|
||||
|
||||
async executeRedo(action) {
|
||||
const projectId = store.get('currentProjectId');
|
||||
|
||||
switch (action.type) {
|
||||
case 'DELETE_TASK':
|
||||
// Re-delete the task
|
||||
await api.deleteTask(projectId, action.task.id);
|
||||
store.removeTask(action.task.id);
|
||||
break;
|
||||
|
||||
case 'CREATE_TASK':
|
||||
// Re-create the task
|
||||
const recreatedTask = await api.createTask(projectId, action.taskData);
|
||||
store.addTask(recreatedTask);
|
||||
break;
|
||||
|
||||
case 'UPDATE_TASK':
|
||||
// Re-apply the update
|
||||
await api.updateTask(projectId, action.taskId, action.newData);
|
||||
store.updateTask(action.taskId, action.newData);
|
||||
break;
|
||||
|
||||
case 'MOVE_TASK':
|
||||
// Re-move to new position
|
||||
await api.moveTask(projectId, action.taskId, action.toColumnId, action.toPosition);
|
||||
store.moveTask(action.taskId, action.toColumnId, action.toPosition);
|
||||
break;
|
||||
|
||||
case 'DELETE_COLUMN':
|
||||
// Re-delete the column
|
||||
await api.deleteColumn(projectId, action.column.id);
|
||||
store.removeColumn(action.column.id);
|
||||
break;
|
||||
|
||||
case 'CREATE_COLUMN':
|
||||
// Re-create the column
|
||||
const recreatedColumn = await api.createColumn(projectId, action.columnData);
|
||||
store.addColumn(recreatedColumn);
|
||||
break;
|
||||
|
||||
case 'UPDATE_COLUMN':
|
||||
// Re-apply the update
|
||||
await api.updateColumn(projectId, action.columnId, action.newData);
|
||||
store.updateColumn(action.columnId, action.newData);
|
||||
break;
|
||||
|
||||
case 'BULK_DELETE':
|
||||
// Re-delete all tasks
|
||||
for (const task of action.tasks) {
|
||||
await api.deleteTask(projectId, task.id);
|
||||
store.removeTask(task.id);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'BULK_MOVE':
|
||||
// Re-move all tasks
|
||||
for (const item of action.items) {
|
||||
await api.moveTask(projectId, item.taskId, item.toColumnId, item.toPosition);
|
||||
store.moveTask(item.taskId, item.toColumnId, item.toPosition);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'BULK_UPDATE':
|
||||
// Re-apply all updates
|
||||
for (const item of action.items) {
|
||||
await api.updateTask(projectId, item.taskId, item.newData);
|
||||
store.updateTask(item.taskId, item.newData);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('Unknown redo action type:', action.type);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// ACTION RECORDING
|
||||
// =====================
|
||||
|
||||
recordTaskDelete(task) {
|
||||
store.pushUndo({
|
||||
type: 'DELETE_TASK',
|
||||
task: { ...task }
|
||||
});
|
||||
}
|
||||
|
||||
recordTaskCreate(taskId, taskData) {
|
||||
store.pushUndo({
|
||||
type: 'CREATE_TASK',
|
||||
taskId,
|
||||
taskData
|
||||
});
|
||||
}
|
||||
|
||||
recordTaskUpdate(taskId, previousData, newData) {
|
||||
store.pushUndo({
|
||||
type: 'UPDATE_TASK',
|
||||
taskId,
|
||||
previousData,
|
||||
newData
|
||||
});
|
||||
}
|
||||
|
||||
recordTaskMove(taskId, fromColumnId, fromPosition, toColumnId, toPosition) {
|
||||
store.pushUndo({
|
||||
type: 'MOVE_TASK',
|
||||
taskId,
|
||||
fromColumnId,
|
||||
fromPosition,
|
||||
toColumnId,
|
||||
toPosition
|
||||
});
|
||||
}
|
||||
|
||||
recordColumnDelete(column) {
|
||||
store.pushUndo({
|
||||
type: 'DELETE_COLUMN',
|
||||
column: { ...column }
|
||||
});
|
||||
}
|
||||
|
||||
recordColumnCreate(columnId, columnData) {
|
||||
store.pushUndo({
|
||||
type: 'CREATE_COLUMN',
|
||||
columnId,
|
||||
columnData
|
||||
});
|
||||
}
|
||||
|
||||
recordColumnUpdate(columnId, previousData, newData) {
|
||||
store.pushUndo({
|
||||
type: 'UPDATE_COLUMN',
|
||||
columnId,
|
||||
previousData,
|
||||
newData
|
||||
});
|
||||
}
|
||||
|
||||
recordBulkDelete(tasks) {
|
||||
store.pushUndo({
|
||||
type: 'BULK_DELETE',
|
||||
tasks: tasks.map(t => ({ ...t }))
|
||||
});
|
||||
}
|
||||
|
||||
recordBulkMove(items) {
|
||||
store.pushUndo({
|
||||
type: 'BULK_MOVE',
|
||||
items: [...items]
|
||||
});
|
||||
}
|
||||
|
||||
recordBulkUpdate(items) {
|
||||
store.pushUndo({
|
||||
type: 'BULK_UPDATE',
|
||||
items: [...items]
|
||||
});
|
||||
}
|
||||
|
||||
// =====================
|
||||
// HELPERS
|
||||
// =====================
|
||||
|
||||
showSuccess(message) {
|
||||
window.dispatchEvent(new CustomEvent('toast:show', {
|
||||
detail: { message, type: 'success' }
|
||||
}));
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
window.dispatchEvent(new CustomEvent('toast:show', {
|
||||
detail: { message, type: 'error' }
|
||||
}));
|
||||
}
|
||||
|
||||
showInfo(message) {
|
||||
window.dispatchEvent(new CustomEvent('toast:show', {
|
||||
detail: { message, type: 'info' }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export singleton
|
||||
const undoManager = new UndoManager();
|
||||
|
||||
export default undoManager;
|
||||
615
frontend/js/utils.js
Normale Datei
615
frontend/js/utils.js
Normale Datei
@ -0,0 +1,615 @@
|
||||
/**
|
||||
* TASKMATE - Utility Functions
|
||||
* ============================
|
||||
*/
|
||||
|
||||
// Date Formatting
|
||||
export function formatDate(dateString, options = {}) {
|
||||
if (!dateString) return '';
|
||||
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
|
||||
if (options.relative) {
|
||||
const diff = date - now;
|
||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) return 'Heute';
|
||||
if (days === 1) return 'Morgen';
|
||||
if (days === -1) return 'Gestern';
|
||||
if (days > 0 && days <= 7) return `In ${days} Tagen`;
|
||||
if (days < 0 && days >= -7) return `Vor ${Math.abs(days)} Tagen`;
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: options.year ? 'numeric' : undefined,
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDateTime(dateString) {
|
||||
if (!dateString) return '';
|
||||
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
export function formatTime(dateString) {
|
||||
if (!dateString) return '';
|
||||
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
export function formatRelativeTime(dateString) {
|
||||
if (!dateString) return '';
|
||||
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diff = now - date;
|
||||
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
if (minutes < 1) return 'Gerade eben';
|
||||
if (minutes < 60) return `Vor ${minutes} Min.`;
|
||||
if (hours < 24) return `Vor ${hours} Std.`;
|
||||
if (days < 7) return `Vor ${days} Tagen`;
|
||||
|
||||
return formatDate(dateString, { year: true });
|
||||
}
|
||||
|
||||
// Time Duration
|
||||
export function formatDuration(hours, minutes) {
|
||||
const parts = [];
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
if (minutes > 0) parts.push(`${minutes}m`);
|
||||
return parts.join(' ') || '0m';
|
||||
}
|
||||
|
||||
export function parseDuration(durationString) {
|
||||
const match = durationString.match(/(\d+)h\s*(\d+)?m?|(\d+)m/);
|
||||
if (!match) return { hours: 0, minutes: 0 };
|
||||
|
||||
if (match[1]) {
|
||||
return {
|
||||
hours: parseInt(match[1]) || 0,
|
||||
minutes: parseInt(match[2]) || 0
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hours: 0,
|
||||
minutes: parseInt(match[3]) || 0
|
||||
};
|
||||
}
|
||||
|
||||
// File Size
|
||||
export function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const k = 1024;
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + units[i];
|
||||
}
|
||||
|
||||
// String Utilities
|
||||
export function truncate(str, length = 50) {
|
||||
if (!str || str.length <= length) return str;
|
||||
return str.substring(0, length) + '...';
|
||||
}
|
||||
|
||||
export function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
export function slugify(str) {
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replace(/[äöüß]/g, match => ({
|
||||
'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'ß': 'ss'
|
||||
}[match]))
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/[\s_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
export function getInitials(name) {
|
||||
if (!name) return '?';
|
||||
|
||||
return name
|
||||
.split(' ')
|
||||
.map(part => part.charAt(0).toUpperCase())
|
||||
.slice(0, 2)
|
||||
.join('');
|
||||
}
|
||||
|
||||
// Color Utilities
|
||||
export function hexToRgba(hex, alpha = 1) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
if (!result) return hex;
|
||||
|
||||
return `rgba(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}, ${alpha})`;
|
||||
}
|
||||
|
||||
export function getContrastColor(hexColor) {
|
||||
const rgb = parseInt(hexColor.slice(1), 16);
|
||||
const r = (rgb >> 16) & 0xff;
|
||||
const g = (rgb >> 8) & 0xff;
|
||||
const b = (rgb >> 0) & 0xff;
|
||||
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return luminance > 0.5 ? '#000000' : '#FFFFFF';
|
||||
}
|
||||
|
||||
// DOM Utilities
|
||||
export function $(selector, context = document) {
|
||||
return context.querySelector(selector);
|
||||
}
|
||||
|
||||
export function $$(selector, context = document) {
|
||||
return Array.from(context.querySelectorAll(selector));
|
||||
}
|
||||
|
||||
export function createElement(tag, attributes = {}, children = []) {
|
||||
const element = document.createElement(tag);
|
||||
|
||||
Object.entries(attributes).forEach(([key, value]) => {
|
||||
if (key === 'className') {
|
||||
element.className = value;
|
||||
} else if (key === 'dataset') {
|
||||
Object.entries(value).forEach(([dataKey, dataValue]) => {
|
||||
element.dataset[dataKey] = dataValue;
|
||||
});
|
||||
} else if (key === 'style' && typeof value === 'object') {
|
||||
Object.assign(element.style, value);
|
||||
} else if (key.startsWith('on') && typeof value === 'function') {
|
||||
element.addEventListener(key.slice(2).toLowerCase(), value);
|
||||
} else if (key === 'checked' || key === 'disabled' || key === 'selected') {
|
||||
// Boolean properties must be set as properties, not attributes
|
||||
element[key] = value;
|
||||
} else {
|
||||
element.setAttribute(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
children.forEach(child => {
|
||||
if (typeof child === 'string') {
|
||||
element.appendChild(document.createTextNode(child));
|
||||
} else if (child instanceof Node) {
|
||||
element.appendChild(child);
|
||||
}
|
||||
});
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
export function removeElement(element) {
|
||||
if (element && element.parentNode) {
|
||||
element.parentNode.removeChild(element);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearElement(element) {
|
||||
while (element.firstChild) {
|
||||
element.removeChild(element.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
// Event Utilities
|
||||
export function debounce(func, wait = 300) {
|
||||
let timeout;
|
||||
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
export function throttle(func, limit = 100) {
|
||||
let inThrottle;
|
||||
|
||||
return function executedFunction(...args) {
|
||||
if (!inThrottle) {
|
||||
func(...args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function onClickOutside(element, callback) {
|
||||
const handler = (event) => {
|
||||
if (!element.contains(event.target)) {
|
||||
callback(event);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handler);
|
||||
|
||||
return () => document.removeEventListener('click', handler);
|
||||
}
|
||||
|
||||
// Array Utilities
|
||||
export function moveInArray(array, fromIndex, toIndex) {
|
||||
const element = array[fromIndex];
|
||||
array.splice(fromIndex, 1);
|
||||
array.splice(toIndex, 0, element);
|
||||
return array;
|
||||
}
|
||||
|
||||
export function groupBy(array, key) {
|
||||
return array.reduce((groups, item) => {
|
||||
const value = typeof key === 'function' ? key(item) : item[key];
|
||||
(groups[value] = groups[value] || []).push(item);
|
||||
return groups;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function sortBy(array, key, direction = 'asc') {
|
||||
const modifier = direction === 'desc' ? -1 : 1;
|
||||
|
||||
return [...array].sort((a, b) => {
|
||||
const aValue = typeof key === 'function' ? key(a) : a[key];
|
||||
const bValue = typeof key === 'function' ? key(b) : b[key];
|
||||
|
||||
if (aValue < bValue) return -1 * modifier;
|
||||
if (aValue > bValue) return 1 * modifier;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
// Object Utilities
|
||||
export function deepClone(obj) {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
export function deepMerge(target, source) {
|
||||
const output = { ...target };
|
||||
|
||||
Object.keys(source).forEach(key => {
|
||||
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
||||
if (target[key] && typeof target[key] === 'object') {
|
||||
output[key] = deepMerge(target[key], source[key]);
|
||||
} else {
|
||||
output[key] = { ...source[key] };
|
||||
}
|
||||
} else {
|
||||
output[key] = source[key];
|
||||
}
|
||||
});
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// URL Utilities
|
||||
export function getUrlDomain(url) {
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidUrl(string) {
|
||||
try {
|
||||
new URL(string);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getFileExtension(filename) {
|
||||
return filename.split('.').pop().toLowerCase();
|
||||
}
|
||||
|
||||
export function isImageFile(filename) {
|
||||
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'];
|
||||
return imageExtensions.includes(getFileExtension(filename));
|
||||
}
|
||||
|
||||
// ID Generation
|
||||
export function generateId() {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
}
|
||||
|
||||
export function generateTempId() {
|
||||
return 'temp_' + generateId();
|
||||
}
|
||||
|
||||
// Local Storage
|
||||
export function getFromStorage(key, defaultValue = null) {
|
||||
try {
|
||||
const item = localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : defaultValue;
|
||||
} catch {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
export function setToStorage(key, value) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function removeFromStorage(key) {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority Stars
|
||||
export function getPriorityStars(priority) {
|
||||
const config = {
|
||||
high: { stars: '★★★', label: 'Hohe Priorität', colorClass: 'priority-high' },
|
||||
medium: { stars: '★★', label: 'Mittlere Priorität', colorClass: 'priority-medium' },
|
||||
low: { stars: '★', label: 'Niedrige Priorität', colorClass: 'priority-low' }
|
||||
};
|
||||
return config[priority] || config.medium;
|
||||
}
|
||||
|
||||
export function createPriorityElement(priority) {
|
||||
const config = getPriorityStars(priority);
|
||||
const span = document.createElement('span');
|
||||
span.className = `priority-stars ${config.colorClass}`;
|
||||
span.textContent = config.stars;
|
||||
span.title = config.label;
|
||||
return span;
|
||||
}
|
||||
|
||||
// Due Date Status
|
||||
export function getDueDateStatus(dueDate) {
|
||||
if (!dueDate) return null;
|
||||
|
||||
const due = new Date(dueDate);
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const dueDay = new Date(due.getFullYear(), due.getMonth(), due.getDate());
|
||||
|
||||
const diffDays = Math.ceil((dueDay - today) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays < 0) return 'overdue';
|
||||
if (diffDays === 0) return 'today';
|
||||
if (diffDays <= 2) return 'soon';
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
// Progress Calculation
|
||||
export function calculateProgress(subtasks) {
|
||||
if (!subtasks || subtasks.length === 0) return null;
|
||||
|
||||
const completed = subtasks.filter(st => st.completed).length;
|
||||
return {
|
||||
completed,
|
||||
total: subtasks.length,
|
||||
percentage: Math.round((completed / subtasks.length) * 100)
|
||||
};
|
||||
}
|
||||
|
||||
// Search/Filter Helpers
|
||||
export function matchesSearch(text, query) {
|
||||
if (!query) return true;
|
||||
if (!text) return false;
|
||||
|
||||
return text.toLowerCase().includes(query.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep search in task content - searches in title, description, subtasks, and labels
|
||||
* @param {Object} task - The task object to search in
|
||||
* @param {string} query - The search query
|
||||
* @returns {boolean} - True if query matches any content
|
||||
*/
|
||||
export function searchInTaskContent(task, query) {
|
||||
if (!query || !query.trim()) return true;
|
||||
|
||||
const searchQuery = query.toLowerCase().trim();
|
||||
|
||||
// Search in title
|
||||
if (task.title && task.title.toLowerCase().includes(searchQuery)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Search in description
|
||||
if (task.description && task.description.toLowerCase().includes(searchQuery)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Search in subtasks
|
||||
if (task.subtasks && Array.isArray(task.subtasks)) {
|
||||
for (const subtask of task.subtasks) {
|
||||
if (subtask.title && subtask.title.toLowerCase().includes(searchQuery)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search in labels
|
||||
if (task.labels && Array.isArray(task.labels)) {
|
||||
for (const label of task.labels) {
|
||||
if (label.name && label.name.toLowerCase().includes(searchQuery)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search in assigned user name
|
||||
if (task.assignedName && task.assignedName.toLowerCase().includes(searchQuery)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function filterTasks(tasks, filters, searchResultIds = [], columns = []) {
|
||||
// Hilfsfunktion: Prüfen ob Aufgabe in der letzten Spalte (erledigt) ist
|
||||
const isTaskCompleted = (task) => {
|
||||
if (!columns || columns.length === 0) return false;
|
||||
const lastColumnId = columns[columns.length - 1].id;
|
||||
return task.columnId === lastColumnId;
|
||||
};
|
||||
|
||||
return tasks.filter(task => {
|
||||
// Search query - use deep search
|
||||
// But allow tasks that were found by server search (for deep content like attachments)
|
||||
if (filters.search) {
|
||||
const isServerResult = searchResultIds.includes(task.id);
|
||||
const matchesClient = searchInTaskContent(task, filters.search);
|
||||
|
||||
if (!isServerResult && !matchesClient) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority filter
|
||||
if (filters.priority && filters.priority !== 'all') {
|
||||
if (task.priority !== filters.priority) return false;
|
||||
}
|
||||
|
||||
// Assignee filter
|
||||
if (filters.assignee && filters.assignee !== 'all') {
|
||||
if (task.assignedTo !== parseInt(filters.assignee)) return false;
|
||||
}
|
||||
|
||||
// Label filter
|
||||
if (filters.label && filters.label !== 'all') {
|
||||
const hasLabel = task.labels?.some(l => l.id === parseInt(filters.label));
|
||||
if (!hasLabel) return false;
|
||||
}
|
||||
|
||||
// Due date filter
|
||||
if (filters.dueDate && filters.dueDate !== 'all' && filters.dueDate !== '') {
|
||||
const status = getDueDateStatus(task.dueDate);
|
||||
|
||||
// Bei "überfällig" erledigte Aufgaben ausschließen
|
||||
if (filters.dueDate === 'overdue') {
|
||||
if (status !== 'overdue' || isTaskCompleted(task)) return false;
|
||||
}
|
||||
if (filters.dueDate === 'today' && status !== 'today') return false;
|
||||
if (filters.dueDate === 'week') {
|
||||
const due = new Date(task.dueDate);
|
||||
const weekFromNow = new Date();
|
||||
weekFromNow.setDate(weekFromNow.getDate() + 7);
|
||||
if (!task.dueDate || due > weekFromNow) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Clipboard
|
||||
export async function copyToClipboard(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// Fallback
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
return true;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard Helpers
|
||||
export function getKeyCombo(event) {
|
||||
const parts = [];
|
||||
|
||||
if (event.ctrlKey || event.metaKey) parts.push('Ctrl');
|
||||
if (event.altKey) parts.push('Alt');
|
||||
if (event.shiftKey) parts.push('Shift');
|
||||
|
||||
if (event.key && !['Control', 'Alt', 'Shift', 'Meta'].includes(event.key)) {
|
||||
parts.push(event.key.toUpperCase());
|
||||
}
|
||||
|
||||
return parts.join('+');
|
||||
}
|
||||
|
||||
// Focus Management
|
||||
export function trapFocus(element) {
|
||||
const focusableElements = element.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
|
||||
const firstFocusable = focusableElements[0];
|
||||
const lastFocusable = focusableElements[focusableElements.length - 1];
|
||||
|
||||
const handleKeydown = (e) => {
|
||||
if (e.key !== 'Tab') return;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstFocusable) {
|
||||
lastFocusable.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastFocusable) {
|
||||
firstFocusable.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
element.addEventListener('keydown', handleKeydown);
|
||||
|
||||
return () => element.removeEventListener('keydown', handleKeydown);
|
||||
}
|
||||
|
||||
// Announcements for Screen Readers
|
||||
export function announce(message, priority = 'polite') {
|
||||
const announcer = document.getElementById('sr-announcer') || createAnnouncer();
|
||||
announcer.setAttribute('aria-live', priority);
|
||||
announcer.textContent = message;
|
||||
}
|
||||
|
||||
function createAnnouncer() {
|
||||
const announcer = document.createElement('div');
|
||||
announcer.id = 'sr-announcer';
|
||||
announcer.setAttribute('aria-live', 'polite');
|
||||
announcer.setAttribute('aria-atomic', 'true');
|
||||
announcer.className = 'sr-only';
|
||||
document.body.appendChild(announcer);
|
||||
return announcer;
|
||||
}
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren