Major refactor: auth, bug fixes, security hardening, modular routes

Bug fixes
- Sync database.js schema with actual production DB (tarifoptionen
  shape was wrong; new installs would have broken).
- Fix SQL syntax error `SELECT to.*` (reserved keyword) in single-
  schaltung provision endpoint, plus the dead fallback hierarchy
  that selected columns that were never in the query.
- Fix PUT /api/tarifoptionen/:id writing to non-existent column
  provision_betrag (should be provision_euro).
- Fix race condition in PUT /api/schaltungen/:id where forEach with
  async db.run inside a transaction could send duplicate responses.
- Delete dokumente rows explicitly before deleting schaltung
  (existing DBs lack ON DELETE CASCADE on dokumente FK).
- Replace hardcoded basisBetrag=100 for percent rules; basis is now
  configurable per rule via the new basis_euro column.

Security
- Multi-user authentication: users table, bcryptjs, express-session
  with SQLite store, login/logout/me/setup/change-password endpoints.
- Auth gate on all /api/* except /api/auth/*.
- CLI to create users: npm run create-user -- <user> <pass> [--admin].
- helmet for security headers.
- CORS restricted to ALLOWED_ORIGINS env (default same-origin).
- Rate limits: 300/min global, 20/15min on login.
- Path-traversal validation on document download/delete.
- XSS hardening in frontend: escapeHtml helper applied to all
  DB-derived innerHTML interpolations; inline onclick handlers that
  embedded user-controlled strings replaced with data-* attrs +
  addEventListener.

Architecture
- Provision calculation centralised in provisionService.js as the
  single source of truth (was duplicated and inconsistent).
- server.js cut from 2138 to ~140 lines; routes split into per-
  resource modules under routes/.
- Promise wrapper db.js for sqlite3 callbacks; async/await in routes.
- middleware/ for auth and async error handling.

Tests
- 14 unit tests for provisionService covering fallback hierarchy,
  percent calculation, tarifoptionen, hardware logic, employee
  commission. All green.

Config
- dotenv loads .env.local at startup (gitignored).
- .gitignore extended for sessions.db and .env*.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
kerimatilganandClaude Opus 4.7 committed 2026-05-14 21:43:19 +02:00
1 parent 55ec916f35
commit 4303b00b9b
23 files changed
+2427 -2575

No files matched your search

+105
View File
@@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Anmeldung - Provisions-Tracker</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #2c3e50;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.card {
background: white;
padding: 40px;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0,0,0,0.2);
width: 100%;
max-width: 400px;
}
h1 {
color: #2c3e50;
margin-bottom: 8px;
font-size: 24px;
}
.subtitle {
color: #7f8c8d;
margin-bottom: 28px;
font-size: 14px;
}
label {
display: block;
margin-bottom: 6px;
color: #34495e;
font-weight: 500;
font-size: 14px;
}
input[type=text], input[type=password] {
width: 100%;
padding: 12px;
border: 1px solid #bdc3c7;
border-radius: 6px;
font-size: 15px;
margin-bottom: 18px;
}
input:focus { outline: none; border-color: #3498db; }
button {
width: 100%;
padding: 12px;
background: #3498db;
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
}
button:hover { background: #2980b9; }
button:disabled { background: #95a5a6; cursor: not-allowed; }
.error {
background: #fee;
color: #c0392b;
padding: 10px;
border-radius: 6px;
margin-bottom: 16px;
font-size: 14px;
display: none;
}
.error.visible { display: block; }
.setup-note {
background: #fff8e1;
color: #8a6d3b;
padding: 10px;
border-radius: 6px;
margin-bottom: 16px;
font-size: 13px;
}
</style>
</head>
<body>
<div class="card">
<h1 id="title">Anmeldung</h1>
<p class="subtitle" id="subtitle">Provisions-Tracker</p>
<div id="setupNote" class="setup-note" style="display:none">
Noch kein Benutzer eingerichtet. Dieser Account wird als Administrator angelegt.
</div>
<div id="errorBox" class="error"></div>
<form id="loginForm" autocomplete="on">
<label for="username">Benutzername</label>
<input type="text" id="username" name="username" required autocomplete="username">
<label for="password">Passwort</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
<button type="submit" id="submitBtn">Anmelden</button>
</form>
</div>
<script src="/login.js"></script>
</body>
</html>
+67
View File
@@ -0,0 +1,67 @@
// Login page logic. Detects whether initial setup is required and adjusts the form.
(async function init() {
const errorBox = document.getElementById('errorBox');
const setupNote = document.getElementById('setupNote');
const title = document.getElementById('title');
const submitBtn = document.getElementById('submitBtn');
const form = document.getElementById('loginForm');
const passwordInput = document.getElementById('password');
let setupMode = false;
function showError(msg) {
errorBox.textContent = msg;
errorBox.classList.add('visible');
}
function clearError() {
errorBox.textContent = '';
errorBox.classList.remove('visible');
}
try {
const r = await fetch('/api/auth/setup-required');
if (r.ok) {
const data = await r.json();
setupMode = !!data.setupRequired;
}
} catch (e) {
// Fall through: stay in login mode
}
if (setupMode) {
title.textContent = 'Ersteinrichtung';
setupNote.style.display = 'block';
submitBtn.textContent = 'Administrator anlegen';
passwordInput.setAttribute('autocomplete', 'new-password');
passwordInput.setAttribute('minlength', '8');
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
clearError();
submitBtn.disabled = true;
const username = document.getElementById('username').value.trim();
const password = passwordInput.value;
try {
const url = setupMode ? '/api/auth/setup' : '/api/auth/login';
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ username, password }),
});
const data = await r.json().catch(() => ({}));
if (!r.ok) {
showError(data.error || 'Anmeldung fehlgeschlagen');
submitBtn.disabled = false;
return;
}
window.location.href = '/';
} catch (err) {
showError('Netzwerkfehler: ' + err.message);
submitBtn.disabled = false;
}
});
})();
+307 -180
View File
@@ -1,24 +1,78 @@
// Global variables
let currentSchaltungId = null;
let uploadedFiles = [];
let currentUser = null;
// Escape user-controlled values before embedding into innerHTML. Replaces all five
// XSS-relevant characters; safe inside both text content and double-quoted attributes.
function escapeHtml(value) {
if (value === null || value === undefined) return '';
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Initialize the application with background loading
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('DOMContentLoaded', async function() {
// Verify session before loading anything else; redirect to /login on 401.
try {
const meRes = await fetch('/api/auth/me', { credentials: 'same-origin' });
if (meRes.status === 401) {
window.location.href = '/login';
return;
}
currentUser = await meRes.json();
renderUserBadge();
} catch (e) {
window.location.href = '/login';
return;
}
loadAnbieter();
loadSchaltungen();
setupFileUpload();
// Set today's date as default
const today = new Date().toISOString().split('T')[0];
document.getElementById('eingabeDatum').value = today;
// Setup form submission
document.getElementById('schaltungForm').addEventListener('submit', handleSchaltungSubmit);
// Load all data in background for faster tab switching
preloadAllData();
});
function renderUserBadge() {
if (!currentUser) return;
let badge = document.getElementById('userBadge');
if (!badge) {
badge = document.createElement('div');
badge.id = 'userBadge';
badge.style.cssText = 'position:fixed;top:12px;right:16px;z-index:9000;background:rgba(255,255,255,0.95);padding:8px 14px;border-radius:6px;font-size:13px;box-shadow:0 2px 6px rgba(0,0,0,0.15);font-family:Segoe UI,Tahoma,sans-serif;';
document.body.appendChild(badge);
}
const name = currentUser.displayName || currentUser.username;
badge.innerHTML = '';
const span = document.createElement('span');
span.textContent = name + ' · ';
const link = document.createElement('a');
link.href = '#';
link.textContent = 'Abmelden';
link.style.color = '#c0392b';
link.style.textDecoration = 'none';
link.addEventListener('click', async (e) => {
e.preventDefault();
try { await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' }); } catch (_) {}
window.location.href = '/login';
});
badge.appendChild(span);
badge.appendChild(link);
}
// Preload all data in background
async function preloadAllData() {
console.log('Preloading all data in background...');
@@ -93,23 +147,29 @@ function showTab(tabName) {
// API Helper functions
async function apiRequest(url, options = {}) {
try {
const response = await fetch(url, options);
// Check if response has content
const opts = Object.assign({ credentials: 'same-origin' }, options);
const response = await fetch(url, opts);
// Session expired or unauthenticated -> back to login
if (response.status === 401) {
window.location.href = '/login';
throw new Error('Nicht angemeldet');
}
const contentType = response.headers.get('content-type');
let data = null;
if (contentType && contentType.includes('application/json')) {
const text = await response.text();
if (text.trim()) {
data = JSON.parse(text);
}
}
if (!response.ok) {
throw new Error(data?.error || `HTTP ${response.status}: ${response.statusText}`);
}
return data;
} catch (error) {
showMessage('Fehler: ' + error.message, 'error');
@@ -215,8 +275,8 @@ async function loadAnbieter() {
tarifAnbieterSelect.innerHTML = '<option value="">Anbieter wählen...</option>';
anbieter.forEach(a => {
anbieterSelect.innerHTML += `<option value="${a.id}">${a.name}</option>`;
tarifAnbieterSelect.innerHTML += `<option value="${a.id}">${a.name}</option>`;
anbieterSelect.innerHTML += `<option value="${a.id}">${escapeHtml(a.name)}</option>`;
tarifAnbieterSelect.innerHTML += `<option value="${a.id}">${escapeHtml(a.name)}</option>`;
});
} catch (error) {
@@ -242,7 +302,7 @@ async function loadTarife() {
tarifSelect.innerHTML = '<option value="">Bitte wählen...</option>';
tarife.forEach(t => {
tarifSelect.innerHTML += `<option value="${t.id}">${t.name}</option>`;
tarifSelect.innerHTML += `<option value="${t.id}">${escapeHtml(t.name)}</option>`;
});
} catch (error) {
@@ -295,9 +355,9 @@ function displayTarifoptionen(tarifoptionen) {
<label style="display: flex; align-items: flex-start; gap: 8px; cursor: pointer;">
<input type="checkbox" name="tarifoptionen" value="${option.id}" style="margin-top: 2px;">
<div>
<div style="font-weight: bold; margin-bottom: 4px;">${option.name}</div>
${option.code ? `<div style="font-size: 0.9em; color: #666; margin-bottom: 4px;">Code: ${option.code}</div>` : ''}
${option.beschreibung ? `<div style="font-size: 0.8em; color: #555;">${option.beschreibung}</div>` : ''}
<div style="font-weight: bold; margin-bottom: 4px;">${escapeHtml(option.name)}</div>
${option.code ? `<div style="font-size: 0.9em; color: #666; margin-bottom: 4px;">Code: ${escapeHtml(option.code)}</div>` : ''}
${option.beschreibung ? `<div style="font-size: 0.8em; color: #555;">${escapeHtml(option.beschreibung)}</div>` : ''}
</div>
</label>
`;
@@ -429,12 +489,17 @@ function displayUploadedFile(file) {
const filesDiv = document.getElementById('uploadedFiles');
const fileDiv = document.createElement('div');
fileDiv.className = 'document-item';
fileDiv.innerHTML = `
<span>${file.name} (${formatFileSize(file.size)})</span>
<button type="button" onclick="removeFile('${file.name}')" class="btn-danger">Entfernen</button>
`;
const span = document.createElement('span');
span.textContent = `${file.name} (${formatFileSize(file.size)})`;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn-danger';
btn.textContent = 'Entfernen';
btn.addEventListener('click', () => removeFile(file.name));
fileDiv.appendChild(span);
fileDiv.appendChild(btn);
filesDiv.appendChild(fileDiv);
}
@@ -497,21 +562,31 @@ async function loadSchaltungen() {
schaltungen.forEach(s => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${s.schaltungs_id}</td>
<td>${formatDate(s.eingabe_datum)}</td>
<td>${formatDate(s.schaltungs_datum)}</td>
<td>${s.vorname} ${s.nachname}</td>
<td>${s.firma || '-'}</td>
<td>${s.rufnummer}</td>
<td>${s.anbieter_name}</td>
<td>${s.tarif_name}</td>
<td>${s.hardware}</td>
<td><button onclick="showDocuments(${s.id})" class="btn-success">Dokumente</button></td>
<td>${escapeHtml(s.schaltungs_id)}</td>
<td>${escapeHtml(formatDate(s.eingabe_datum))}</td>
<td>${escapeHtml(formatDate(s.schaltungs_datum))}</td>
<td>${escapeHtml(s.vorname)} ${escapeHtml(s.nachname)}</td>
<td>${escapeHtml(s.firma || '-')}</td>
<td>${escapeHtml(s.rufnummer)}</td>
<td>${escapeHtml(s.anbieter_name)}</td>
<td>${escapeHtml(s.tarif_name)}</td>
<td>${escapeHtml(s.hardware || '')}</td>
<td><button data-action="documents" data-id="${s.id}" class="btn-success">Dokumente</button></td>
<td>
<button onclick="editSchaltung(${s.id})" class="btn-success" style="margin-right: 5px;">Bearbeiten</button>
<button onclick="deleteSchaltung(${s.id}, '${s.schaltungs_id}', '${s.vorname} ${s.nachname}')" class="btn-danger">Löschen</button>
<button data-action="edit" data-id="${s.id}" class="btn-success" style="margin-right: 5px;">Bearbeiten</button>
<button data-action="delete" data-id="${s.id}" data-schaltungs-id="${escapeHtml(s.schaltungs_id)}" data-name="${escapeHtml(s.vorname + ' ' + s.nachname)}" class="btn-danger">Löschen</button>
</td>
`;
// Wire delegated handlers so we don't need inline onclick (avoids JS-injection via names).
row.querySelectorAll('button[data-action]').forEach(btn => {
btn.addEventListener('click', () => {
const action = btn.dataset.action;
const id = parseInt(btn.dataset.id, 10);
if (action === 'documents') showDocuments(id);
else if (action === 'edit') editSchaltung(id);
else if (action === 'delete') deleteSchaltung(id, btn.dataset.schaltungsId, btn.dataset.name);
});
});
tbody.appendChild(row);
});
@@ -565,31 +640,38 @@ async function showDocuments(schaltungId) {
// Format upload date
const uploadDate = doc.uploaded_at ? formatDate(doc.uploaded_at) : 'Unbekannt';
// Add view button for PDFs
const viewButton = fileExt === 'pdf'
? `<button class="view-btn" onclick="viewPdfDocument(${doc.id}, '${doc.original_filename.replace(/'/g, "\\'")}')">👁️ Anzeigen</button>`
: '';
const showView = fileExt === 'pdf';
const safeName = escapeHtml(doc.original_filename);
docItem.innerHTML = `
<div class="document-info">
<div class="document-icon">${icon}</div>
<div class="document-details">
<div class="document-name">${doc.original_filename}</div>
<div class="document-meta">Größe: ${fileSize} • Hochgeladen: ${uploadDate}</div>
<div class="document-name">${safeName}</div>
<div class="document-meta">Größe: ${escapeHtml(fileSize)} • Hochgeladen: ${escapeHtml(uploadDate)}</div>
</div>
</div>
<div style="display: flex; gap: 8px;">
${viewButton}
<button class="download-btn" onclick="downloadDocument(${doc.id})">
${showView ? `<button class="view-btn" data-doc-action="view" data-id="${doc.id}" data-name="${safeName}">👁️ Anzeigen</button>` : ''}
<button class="download-btn" data-doc-action="download" data-id="${doc.id}">
📥 Download
</button>
<button class="btn-danger" onclick="deleteDocument(${doc.id}, '${doc.original_filename.replace(/'/g, "\\'")}', ${schaltungId})"
<button class="btn-danger" data-doc-action="delete" data-id="${doc.id}" data-name="${safeName}" data-schaltung-id="${schaltungId}"
style="background: #dc3545; border-color: #dc3545; color: white; padding: 8px 12px; border-radius: 4px; font-size: 12px;"
title="Dokument löschen">
🗑️
</button>
</div>
`;
docItem.querySelectorAll('button[data-doc-action]').forEach(btn => {
btn.addEventListener('click', () => {
const action = btn.dataset.docAction;
const id = parseInt(btn.dataset.id, 10);
if (action === 'view') viewPdfDocument(id, btn.dataset.name);
else if (action === 'download') downloadDocument(id);
else if (action === 'delete') deleteDocument(id, btn.dataset.name, parseInt(btn.dataset.schaltungId, 10));
});
});
modalBody.appendChild(docItem);
});
@@ -760,20 +842,21 @@ async function loadAnbieterAdmin() {
}
anbieter.forEach(a => {
// Format the date nicely
const createdAt = new Date(a.created_at).toLocaleDateString('de-DE');
const row = `
<tr>
<td>${a.id}</td>
<td>${a.name}</td>
<td>${createdAt}</td>
<td>
<button onclick="deleteAnbieter(${a.id}, '${a.name}')" class="btn-danger">Löschen</button>
</td>
</tr>
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${a.id}</td>
<td>${escapeHtml(a.name)}</td>
<td>${escapeHtml(createdAt)}</td>
<td>
<button data-anbieter-action="delete" data-id="${a.id}" data-name="${escapeHtml(a.name)}" class="btn-danger">Löschen</button>
</td>
`;
tbody.innerHTML += row;
tr.querySelector('button[data-anbieter-action="delete"]').addEventListener('click', (e) => {
const btn = e.currentTarget;
deleteAnbieter(parseInt(btn.dataset.id, 10), btn.dataset.name);
});
tbody.appendChild(tr);
});
} catch (error) {
@@ -807,21 +890,28 @@ async function loadTarifeAdmin() {
tarife.forEach(t => {
const provision = `${(t.provision_euro || 0).toFixed(2).replace('.', ',')}€`;
const row = `
<tr id="tarif-item-${t.id}">
<td>${t.id}</td>
<td>${t.anbieter_name || 'Unbekannt'}</td>
<td>${t.name}</td>
<td>${provision}</td>
<td>${t.beschreibung || '-'}</td>
<td>
<button onclick="editTariff(${t.id})" class="btn-success" style="margin-right: 5px;">Bearbeiten</button>
<button onclick="deleteTariff(${t.id}, '${t.name}')" class="btn-danger">Löschen</button>
</td>
</tr>
const tr = document.createElement('tr');
tr.id = `tarif-item-${t.id}`;
tr.innerHTML = `
<td>${t.id}</td>
<td>${escapeHtml(t.anbieter_name || 'Unbekannt')}</td>
<td>${escapeHtml(t.name)}</td>
<td>${escapeHtml(provision)}</td>
<td>${escapeHtml(t.beschreibung || '-')}</td>
<td>
<button data-tarif-action="edit" data-id="${t.id}" class="btn-success" style="margin-right: 5px;">Bearbeiten</button>
<button data-tarif-action="delete" data-id="${t.id}" data-name="${escapeHtml(t.name)}" class="btn-danger">Löschen</button>
</td>
`;
tbody.innerHTML += row;
tr.querySelectorAll('button[data-tarif-action]').forEach(btn => {
btn.addEventListener('click', () => {
const action = btn.dataset.tarifAction;
const id = parseInt(btn.dataset.id, 10);
if (action === 'edit') editTariff(id);
else if (action === 'delete') deleteTariff(id, btn.dataset.name);
});
});
tbody.appendChild(tr);
});
} catch (error) {
@@ -870,7 +960,7 @@ async function loadAnbieterForTarife() {
select.innerHTML = '<option value="">Anbieter wählen...</option>';
anbieter.forEach(a => {
select.innerHTML += `<option value="${a.id}">${a.name}</option>`;
select.innerHTML += `<option value="${a.id}">${escapeHtml(a.name)}</option>`;
});
} catch (error) {
console.error('Fehler beim Laden der Anbieter für Tarife:', error);
@@ -885,7 +975,7 @@ async function loadMitarbeiterAnbieter() {
select.innerHTML = '<option value="">Anbieter wählen...</option>';
anbieter.forEach(a => {
select.innerHTML += `<option value="${a.id}">${a.name}</option>`;
select.innerHTML += `<option value="${a.id}">${escapeHtml(a.name)}</option>`;
});
} catch (error) {
console.error('Fehler beim Laden der Anbieter für Mitarbeiter:', error);
@@ -900,7 +990,7 @@ async function loadTarifoptionAnbieter() {
select.innerHTML = '<option value="">Anbieter wählen...</option>';
anbieter.forEach(a => {
select.innerHTML += `<option value="${a.id}">${a.name}</option>`;
select.innerHTML += `<option value="${a.id}">${escapeHtml(a.name)}</option>`;
});
} catch (error) {
console.error('Fehler beim Laden der Anbieter für Tarifoptionen:', error);
@@ -1001,7 +1091,7 @@ async function loadTarifeForProvision() {
const tarife = await apiRequest(`/api/tarife/${a.id}`);
if (tarife.length > 0) {
tarife.forEach(t => {
provisionTarifSelect.innerHTML += `<option value="${t.id}">${a.name} - ${t.name}</option>`;
provisionTarifSelect.innerHTML += `<option value="${t.id}">${escapeHtml(a.name + ' - ' + t.name)}</option>`;
});
}
}
@@ -1028,25 +1118,31 @@ async function loadProvisionsregelnAdmin() {
if (regeln.length > 0) {
const providerDiv = document.createElement('div');
providerDiv.innerHTML = `<h4>${a.name} - ${t.name}</h4>`;
const h4 = document.createElement('h4');
h4.textContent = `${a.name} - ${t.name}`;
providerDiv.appendChild(h4);
provisionsregelnList.appendChild(providerDiv);
regeln.forEach(r => {
const item = document.createElement('div');
item.className = 'list-item';
item.style.marginLeft = '20px';
const typText = r.provision_typ === 'fix' ? `${r.betrag} €` : `${r.betrag} %`;
const statusText = r.aktiv ? 'Aktiv' : 'Inaktiv';
item.innerHTML = `
<span>${typText} - ${r.beschreibung || 'Keine Beschreibung'} (${statusText})</span>
<span>${escapeHtml(typText)} - ${escapeHtml(r.beschreibung || 'Keine Beschreibung')} (${escapeHtml(statusText)})</span>
<div>
<button onclick="toggleProvisionsregel(${r.id}, ${!r.aktiv})" class="btn-${r.aktiv ? 'danger' : 'success'}">
<button data-regel-action="toggle" data-id="${r.id}" data-aktiv="${!r.aktiv}" class="btn-${r.aktiv ? 'danger' : 'success'}">
${r.aktiv ? 'Deaktivieren' : 'Aktivieren'}
</button>
</div>
`;
item.querySelector('button[data-regel-action]').addEventListener('click', (e) => {
const btn = e.currentTarget;
toggleProvisionsregel(parseInt(btn.dataset.id, 10), btn.dataset.aktiv === 'true');
});
provisionsregelnList.appendChild(item);
});
}
@@ -1144,7 +1240,7 @@ async function loadGlobalProvisionAnbieter() {
globalProvisionAnbieterSelect.innerHTML = '<option value="">Anbieter wählen...</option>';
anbieter.forEach(a => {
globalProvisionAnbieterSelect.innerHTML += `<option value="${a.id}">${a.name}</option>`;
globalProvisionAnbieterSelect.innerHTML += `<option value="${a.id}">${escapeHtml(a.name)}</option>`;
});
} catch (error) {
@@ -1166,36 +1262,41 @@ async function loadGlobalProvisionsregelnAdmin() {
if (regeln.length > 0) {
const providerDiv = document.createElement('div');
providerDiv.innerHTML = `<h4>Globale Regeln für ${a.name}</h4>`;
const h4 = document.createElement('h4');
h4.textContent = `Globale Regeln für ${a.name}`;
providerDiv.appendChild(h4);
globalProvisionsregelnList.appendChild(providerDiv);
regeln.forEach(r => {
const item = document.createElement('div');
item.className = 'list-item';
item.style.marginLeft = '20px';
// Show employee commission instead of shop commission
let displayText = 'Keine Mitarbeiter-Provision definiert';
if (r.verkaeufer_provision_typ && r.verkaeufer_provision_betrag) {
const mitarbeiterText = r.verkaeufer_provision_typ === 'fix'
? `${r.verkaeufer_provision_betrag} €`
const mitarbeiterText = r.verkaeufer_provision_typ === 'fix'
? `${r.verkaeufer_provision_betrag} €`
: `${r.verkaeufer_provision_betrag} %`;
displayText = `Mitarbeiter: ${mitarbeiterText}`;
if (r.verkaeufer_provision_beschreibung) {
displayText += ` - ${r.verkaeufer_provision_beschreibung}`;
}
}
const statusText = r.aktiv ? 'Aktiv' : 'Inaktiv';
item.innerHTML = `
<span>${displayText} (${statusText})</span>
<span>${escapeHtml(displayText)} (${escapeHtml(statusText)})</span>
<div>
<button onclick="toggleGlobalProvisionsregel(${r.id}, ${!r.aktiv})" class="btn-${r.aktiv ? 'danger' : 'success'}">
<button data-global-action="toggle" data-id="${r.id}" data-aktiv="${!r.aktiv}" class="btn-${r.aktiv ? 'danger' : 'success'}">
${r.aktiv ? 'Deaktivieren' : 'Aktivieren'}
</button>
</div>
`;
item.querySelector('button[data-global-action]').addEventListener('click', (e) => {
const btn = e.currentTarget;
toggleGlobalProvisionsregel(parseInt(btn.dataset.id, 10), btn.dataset.aktiv === 'true');
});
globalProvisionsregelnList.appendChild(item);
});
}
@@ -1417,7 +1518,7 @@ async function editSchaltung(id) {
editAnbieterSelect.innerHTML = '<option value="">Bitte wählen...</option>';
anbieter.forEach(a => {
editAnbieterSelect.innerHTML += `<option value="${a.id}">${a.name}</option>`;
editAnbieterSelect.innerHTML += `<option value="${a.id}">${escapeHtml(a.name)}</option>`;
});
// Fill modal with schaltung data
@@ -1581,7 +1682,7 @@ async function loadTarifeForEdit() {
tarifSelect.innerHTML = '<option value="">Bitte wählen...</option>';
tarife.forEach(t => {
tarifSelect.innerHTML += `<option value="${t.id}">${t.name}</option>`;
tarifSelect.innerHTML += `<option value="${t.id}">${escapeHtml(t.name)}</option>`;
});
// Clear tarifoptionen when anbieter changes
@@ -1716,17 +1817,17 @@ async function loadGeloeschteSchaltungen() {
geloeschte.forEach(s => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${s.schaltungs_id}</td>
<td>${formatDate(s.eingabe_datum)}</td>
<td>${formatDate(s.schaltungs_datum)}</td>
<td>${s.vorname} ${s.nachname}</td>
<td>${s.firma || '-'}</td>
<td>${s.rufnummer}</td>
<td>${s.anbieter_name}</td>
<td>${s.tarif_name}</td>
<td>${s.hardware}</td>
<td><strong style="color: #e74c3c;">${s.loeschgrund}</strong></td>
<td>${formatDateTime(s.geloescht_am)}</td>
<td>${escapeHtml(s.schaltungs_id)}</td>
<td>${escapeHtml(formatDate(s.eingabe_datum))}</td>
<td>${escapeHtml(formatDate(s.schaltungs_datum))}</td>
<td>${escapeHtml(s.vorname)} ${escapeHtml(s.nachname)}</td>
<td>${escapeHtml(s.firma || '-')}</td>
<td>${escapeHtml(s.rufnummer)}</td>
<td>${escapeHtml(s.anbieter_name)}</td>
<td>${escapeHtml(s.tarif_name)}</td>
<td>${escapeHtml(s.hardware || '')}</td>
<td><strong style="color: #e74c3c;">${escapeHtml(s.loeschgrund)}</strong></td>
<td>${escapeHtml(formatDateTime(s.geloescht_am))}</td>
`;
tbody.appendChild(row);
});
@@ -1775,7 +1876,7 @@ async function updateFilterDropdowns() {
if (anbieterOverview) {
anbieterOverview.innerHTML = '<option value="">Alle Anbieter</option>';
anbieter.forEach(a => {
anbieterOverview.innerHTML += `<option value="${a.name}">${a.name}</option>`;
anbieterOverview.innerHTML += `<option value="${escapeHtml(a.name)}">${escapeHtml(a.name)}</option>`;
});
}
@@ -1784,7 +1885,7 @@ async function updateFilterDropdowns() {
if (anbieterProvisionen) {
anbieterProvisionen.innerHTML = '<option value="">Alle Anbieter</option>';
anbieter.forEach(a => {
anbieterProvisionen.innerHTML += `<option value="${a.name}">${a.name}</option>`;
anbieterProvisionen.innerHTML += `<option value="${escapeHtml(a.name)}">${escapeHtml(a.name)}</option>`;
});
}
@@ -1808,10 +1909,10 @@ async function updateFilterDropdowns() {
const optionValue = t.name;
if (tarifOverview) {
tarifOverview.innerHTML += `<option value="${optionValue}">${optionText}</option>`;
tarifOverview.innerHTML += `<option value="${escapeHtml(optionValue)}">${escapeHtml(optionText)}</option>`;
}
if (tarifProvisionen) {
tarifProvisionen.innerHTML += `<option value="${optionValue}">${optionText}</option>`;
tarifProvisionen.innerHTML += `<option value="${escapeHtml(optionValue)}">${escapeHtml(optionText)}</option>`;
}
});
} catch (error) {
@@ -1961,23 +2062,32 @@ function updateSchaltungenTable(schaltungen) {
schaltungen.forEach(s => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${s.schaltungs_id}</td>
<td>${formatDate(s.eingabe_datum)}</td>
<td>${formatDate(s.schaltungs_datum)}</td>
<td>${s.vorname} ${s.nachname}</td>
<td>${s.firma || '-'}</td>
<td>${s.rufnummer}</td>
<td>${s.anbieter_name}</td>
<td>${s.tarif_name}</td>
<td>${s.hardware}</td>
<td>${escapeHtml(s.schaltungs_id)}</td>
<td>${escapeHtml(formatDate(s.eingabe_datum))}</td>
<td>${escapeHtml(formatDate(s.schaltungs_datum))}</td>
<td>${escapeHtml(s.vorname)} ${escapeHtml(s.nachname)}</td>
<td>${escapeHtml(s.firma || '-')}</td>
<td>${escapeHtml(s.rufnummer)}</td>
<td>${escapeHtml(s.anbieter_name)}</td>
<td>${escapeHtml(s.tarif_name)}</td>
<td>${escapeHtml(s.hardware || '')}</td>
<td>
<button onclick="showDocuments(${s.id})" class="btn-success">Dokumente</button>
<button data-filt-action="documents" data-id="${s.id}" class="btn-success">Dokumente</button>
</td>
<td>
<button onclick="editSchaltung(${s.id})" class="btn-secondary">Bearbeiten</button>
<button onclick="deleteSchaltung(${s.id})" class="btn-danger">Löschen</button>
<button data-filt-action="edit" data-id="${s.id}" class="btn-secondary">Bearbeiten</button>
<button data-filt-action="delete" data-id="${s.id}" data-schaltungs-id="${escapeHtml(s.schaltungs_id)}" data-name="${escapeHtml(s.vorname + ' ' + s.nachname)}" class="btn-danger">Löschen</button>
</td>
`;
row.querySelectorAll('button[data-filt-action]').forEach(btn => {
btn.addEventListener('click', () => {
const action = btn.dataset.filtAction;
const id = parseInt(btn.dataset.id, 10);
if (action === 'documents') showDocuments(id);
else if (action === 'edit') editSchaltung(id);
else if (action === 'delete') deleteSchaltung(id, btn.dataset.schaltungsId, btn.dataset.name);
});
});
tbody.appendChild(row);
});
}
@@ -2010,15 +2120,15 @@ function updateProvisionenTable(schaltungen, totalShopProvision, totalMitarbeite
schaltungen.forEach(s => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${s.schaltungs_id}</td>
<td>${formatDate(s.schaltungs_datum)}</td>
<td>${s.vorname} ${s.nachname}</td>
<td>${s.anbieter_name}</td>
<td>${s.tarif_name}</td>
<td><strong>${s.berechnete_shop_provision || '0.00'} €</strong></td>
<td><strong>${s.berechnete_mitarbeiter_provision || '0.00'} €</strong></td>
<td>${escapeHtml(s.schaltungs_id)}</td>
<td>${escapeHtml(formatDate(s.schaltungs_datum))}</td>
<td>${escapeHtml(s.vorname)} ${escapeHtml(s.nachname)}</td>
<td>${escapeHtml(s.anbieter_name)}</td>
<td>${escapeHtml(s.tarif_name)}</td>
<td><strong>${escapeHtml(s.berechnete_shop_provision || '0.00')} €</strong></td>
<td><strong>${escapeHtml(s.berechnete_mitarbeiter_provision || '0.00')} €</strong></td>
`;
tbody.appendChild(row);
});
@@ -2106,7 +2216,7 @@ async function loadAnbieterForTarifoptionen() {
const select = document.getElementById('tarifoptionAnbieter');
select.innerHTML = '<option value="">Anbieter wählen...</option>';
anbieter.forEach(a => {
select.innerHTML += `<option value="${a.id}">${a.name}</option>`;
select.innerHTML += `<option value="${a.id}">${escapeHtml(a.name)}</option>`;
});
} catch (error) {
console.error('Fehler beim Laden der Anbieter:', error);
@@ -2136,7 +2246,7 @@ async function loadTarifeForOptions() {
<div style="margin-bottom: 8px;">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" value="${tarif.id}" class="tarif-checkbox" style="margin: 0;">
<span>${tarif.name}</span>
<span>${escapeHtml(tarif.name)}</span>
</label>
</div>
`;
@@ -2226,15 +2336,14 @@ async function loadTarifeForEditOptions() {
let checkboxHtml = '<div style="padding: 15px;">';
tarife.forEach((tarif, index) => {
const provisionText = tarif.provision_euro ? ` (${tarif.provision_euro}€)` : '';
checkboxHtml += `
<div class="edit-tarif-item" style="margin-bottom: 12px; padding: 12px; border-radius: 6px; background: #f8f9fa; border: 1px solid #e9ecef; transition: all 0.2s;" data-tarif-name="${tarif.name.toLowerCase()}">
<div class="edit-tarif-item" style="margin-bottom: 12px; padding: 12px; border-radius: 6px; background: #f8f9fa; border: 1px solid #e9ecef; transition: all 0.2s;" data-tarif-name="${escapeHtml(tarif.name.toLowerCase())}">
<label style="display: flex; align-items: center; gap: 12px; cursor: pointer; margin: 0;">
<input type="checkbox" value="${tarif.id}" class="edit-tarif-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #007bff;">
<div style="flex: 1;">
<div style="font-weight: 500; color: #333; margin-bottom: 2px;">${tarif.name}</div>
${tarif.beschreibung ? `<div style="font-size: 12px; color: #6c757d;">${tarif.beschreibung}</div>` : ''}
${tarif.provision_euro ? `<div style="font-size: 12px; color: #28a745; font-weight: 500;">Provision: ${tarif.provision_euro}€</div>` : ''}
<div style="font-weight: 500; color: #333; margin-bottom: 2px;">${escapeHtml(tarif.name)}</div>
${tarif.beschreibung ? `<div style="font-size: 12px; color: #6c757d;">${escapeHtml(tarif.beschreibung)}</div>` : ''}
${tarif.provision_euro ? `<div style="font-size: 12px; color: #28a745; font-weight: 500;">Provision: ${escapeHtml(String(tarif.provision_euro))}€</div>` : ''}
</div>
</label>
</div>
@@ -2336,7 +2445,7 @@ async function loadAnbieterForAddModal() {
const anbieterSelect = document.getElementById('addTarifoptionAnbieter');
anbieterSelect.innerHTML = '<option value="">Anbieter wählen...</option>';
anbieter.forEach(a => {
anbieterSelect.innerHTML += `<option value="${a.id}">${a.name}</option>`;
anbieterSelect.innerHTML += `<option value="${a.id}">${escapeHtml(a.name)}</option>`;
});
} catch (error) {
console.error('Fehler beim Laden der Anbieter:', error);
@@ -2388,13 +2497,13 @@ async function loadTarifeForAddOptions() {
let checkboxHtml = '<div style="padding: 15px;">';
tarife.forEach((tarif, index) => {
checkboxHtml += `
<div class="add-tarif-item" style="margin-bottom: 12px; padding: 12px; border-radius: 6px; background: #f8f9fa; border: 1px solid #e9ecef; transition: all 0.2s;" data-tarif-name="${tarif.name.toLowerCase()}">
<div class="add-tarif-item" style="margin-bottom: 12px; padding: 12px; border-radius: 6px; background: #f8f9fa; border: 1px solid #e9ecef; transition: all 0.2s;" data-tarif-name="${escapeHtml(tarif.name.toLowerCase())}">
<label style="display: flex; align-items: center; gap: 12px; cursor: pointer; margin: 0;">
<input type="checkbox" value="${tarif.id}" class="add-tarif-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #28a745;">
<div style="flex: 1;">
<div style="font-weight: 500; color: #333; margin-bottom: 2px;">${tarif.name}</div>
${tarif.beschreibung ? `<div style="font-size: 12px; color: #6c757d;">${tarif.beschreibung}</div>` : ''}
${tarif.provision_euro ? `<div style="font-size: 12px; color: #28a745; font-weight: 500;">Provision: ${tarif.provision_euro}€</div>` : ''}
<div style="font-weight: 500; color: #333; margin-bottom: 2px;">${escapeHtml(tarif.name)}</div>
${tarif.beschreibung ? `<div style="font-size: 12px; color: #6c757d;">${escapeHtml(tarif.beschreibung)}</div>` : ''}
${tarif.provision_euro ? `<div style="font-size: 12px; color: #28a745; font-weight: 500;">Provision: ${escapeHtml(String(tarif.provision_euro))}€</div>` : ''}
</div>
</label>
</div>
@@ -2627,37 +2736,49 @@ async function displayTarifoptionenAdmin() {
optionen.forEach(option => {
const provision = `${(option.provision_euro || 0).toFixed(2).replace('.', ',')}€`;
// Handle both old and new format
let tarifDisplay;
// tarifDisplayHtml is the rendered HTML; tarifPlain is the plain-text variant for the title attr.
let tarifDisplayHtml;
let tarifPlain;
if (option.tarif_names) {
// New format with multiple tarifs
tarifDisplay = option.tarif_names || '<em style="color: #28a745;">Alle Tarife</em>';
tarifPlain = option.tarif_names || 'Alle Tarife';
tarifDisplayHtml = option.tarif_names
? escapeHtml(option.tarif_names)
: '<em style="color: #28a745;">Alle Tarife</em>';
} else {
// Old format with single tarif
tarifDisplay = option.tarif_id ?
(option.tarif_name || 'Unbekannter Tarif') :
'<em style="color: #28a745;">Alle Tarife</em>';
if (option.tarif_id) {
tarifPlain = option.tarif_name || 'Unbekannter Tarif';
tarifDisplayHtml = escapeHtml(tarifPlain);
} else {
tarifPlain = 'Alle Tarife';
tarifDisplayHtml = '<em style="color: #28a745;">Alle Tarife</em>';
}
}
// For new format, we may not have anbieter_name directly
const anbieterName = option.anbieter_name || 'Mehrere Anbieter';
const row = `
<tr>
<td>${option.id}</td>
<td>${anbieterName}</td>
<td style="max-width: 300px; overflow: hidden; text-overflow: ellipsis;" title="${tarifDisplay.replace(/<[^>]*>/g, '')}">${tarifDisplay}</td>
<td>${option.name}</td>
<td>${option.code || '-'}</td>
<td>${provision}</td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis;">${option.beschreibung || '-'}</td>
<td>
<button onclick="editTarifoption(${option.id})" class="btn-success" style="margin-right: 5px;">Bearbeiten</button>
<button onclick="deleteTarifoption(${option.id})" class="btn-danger">Löschen</button>
</td>
</tr>
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${option.id}</td>
<td>${escapeHtml(anbieterName)}</td>
<td style="max-width: 300px; overflow: hidden; text-overflow: ellipsis;" title="${escapeHtml(tarifPlain)}">${tarifDisplayHtml}</td>
<td>${escapeHtml(option.name)}</td>
<td>${escapeHtml(option.code || '-')}</td>
<td>${escapeHtml(provision)}</td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(option.beschreibung || '-')}</td>
<td>
<button data-to-action="edit" data-id="${option.id}" class="btn-success" style="margin-right: 5px;">Bearbeiten</button>
<button data-to-action="delete" data-id="${option.id}" class="btn-danger">Löschen</button>
</td>
`;
tbody.innerHTML += row;
tr.querySelectorAll('button[data-to-action]').forEach(btn => {
btn.addEventListener('click', () => {
const action = btn.dataset.toAction;
const id = parseInt(btn.dataset.id, 10);
if (action === 'edit') editTarifoption(id);
else if (action === 'delete') deleteTarifoption(id);
});
});
tbody.appendChild(tr);
});
} catch (error) {
console.error('Fehler beim Laden der Tarifoptionen:', error);
@@ -2749,7 +2870,7 @@ async function editTarifoption(optionId) {
const anbieterSelect = document.getElementById('editTarifoptionAnbieter');
anbieterSelect.innerHTML = '<option value="">Anbieter wählen...</option>';
anbieter.forEach(a => {
anbieterSelect.innerHTML += `<option value="${a.id}">${a.name}</option>`;
anbieterSelect.innerHTML += `<option value="${a.id}">${escapeHtml(a.name)}</option>`;
});
// Try to get from new API first, fallback to old API
@@ -2949,16 +3070,16 @@ async function loadMitarbeiterProzentsaetzeAdmin() {
providerDiv.className = 'admin-card';
providerDiv.innerHTML = `
<div class="admin-card-header">
<h4>${a.name}</h4>
<h4>${escapeHtml(a.name)}</h4>
</div>
<div class="admin-card-content">
<div class="prozentsaetze-list" id="prozentsaetze-${a.id}"></div>
</div>
`;
container.appendChild(providerDiv);
const prozentsaetzeList = document.getElementById(`prozentsaetze-${a.id}`);
if (prozentsaetze.length === 0) {
prozentsaetzeList.innerHTML = '<p class="text-muted">Keine Mitarbeiter-Prozentsätze definiert</p>';
} else {
@@ -2967,16 +3088,19 @@ async function loadMitarbeiterProzentsaetzeAdmin() {
item.className = 'list-item';
item.innerHTML = `
<div>
<strong>${p.prozentsatz}%</strong>
${p.beschreibung ? `- ${p.beschreibung}` : ''}
<br><small class="text-muted">Gültig ab: ${p.gueltig_ab}</small>
<strong>${escapeHtml(String(p.prozentsatz))}%</strong>
${p.beschreibung ? `- ${escapeHtml(p.beschreibung)}` : ''}
<br><small class="text-muted">Gültig ab: ${escapeHtml(p.gueltig_ab)}</small>
</div>
<div>
<button onclick="deleteMitarbeiterProzentsatz(${p.id})" class="btn-danger">
<button data-mp-action="delete" data-id="${p.id}" class="btn-danger">
Löschen
</button>
</div>
`;
item.querySelector('button[data-mp-action]').addEventListener('click', (e) => {
deleteMitarbeiterProzentsatz(parseInt(e.currentTarget.dataset.id, 10));
});
prozentsaetzeList.appendChild(item);
});
}
@@ -2995,7 +3119,7 @@ async function loadMitarbeiterAnbieter() {
select.innerHTML = '<option value="">Anbieter wählen...</option>';
anbieter.forEach(a => {
select.innerHTML += `<option value="${a.id}">${a.name}</option>`;
select.innerHTML += `<option value="${a.id}">${escapeHtml(a.name)}</option>`;
});
} catch (error) {
@@ -3081,21 +3205,21 @@ async function loadTarifProvisionsAdmin() {
tarifDiv.className = 'admin-card';
tarifDiv.innerHTML = `
<div class="admin-card-header">
<h4>${tarif.name} (${tarif.anbieter_name})</h4>
<span class="text-muted">Basis-Provision: ${tarif.provision_euro}€</span>
<h4>${escapeHtml(tarif.name)} (${escapeHtml(tarif.anbieter_name)})</h4>
<span class="text-muted">Basis-Provision: ${escapeHtml(String(tarif.provision_euro))}€</span>
</div>
<div class="admin-card-content">
<div class="tarif-provision-controls">
<div class="form-row">
<label>Mitarbeiter-Provision (€):</label>
<input type="number"
id="provision-${tarif.id}"
placeholder="0.00"
step="0.01"
<input type="number"
id="provision-${tarif.id}"
placeholder="0.00"
step="0.01"
min="0"
value="${tarif.mitarbeiter_provision || ''}"
value="${escapeHtml(String(tarif.mitarbeiter_provision || ''))}"
style="width: 120px;">
<button onclick="saveTarifProvision(${tarif.id})" class="btn-success">
<button data-tp-action="save" data-id="${tarif.id}" class="btn-success">
Speichern
</button>
</div>
@@ -3105,6 +3229,9 @@ async function loadTarifProvisionsAdmin() {
</div>
</div>
`;
tarifDiv.querySelector('button[data-tp-action]').addEventListener('click', (e) => {
saveTarifProvision(parseInt(e.currentTarget.dataset.id, 10));
});
container.appendChild(tarifDiv);
}