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
+2390 -2538

No files matched your search

+6
View File
@@ -13,6 +13,12 @@ uploads/
# Local Claude settings
.claude/
# Local environment / secrets
.env
.env.local
.env.*.local
sessions.db
# OS / Editor
.DS_Store
Thumbs.db
+93 -137
View File
@@ -3,10 +3,8 @@ const path = require('path');
const dbPath = path.join(__dirname, 'provision.db');
// Singleton database instance
let dbInstance = null;
// Get or create database instance (Singleton pattern)
function getDatabase() {
if (!dbInstance) {
dbInstance = new sqlite3.Database(dbPath, (err) => {
@@ -18,13 +16,11 @@ function getDatabase() {
console.log('Connected to SQLite database');
});
// Set busy timeout to handle concurrent access
dbInstance.configure('busyTimeout', 3000);
}
return dbInstance;
}
// Enable foreign keys for a database connection
function enableForeignKeys(db) {
db.run('PRAGMA foreign_keys = ON', (err) => {
if (err) {
@@ -33,7 +29,6 @@ function enableForeignKeys(db) {
});
}
// Close database connection (for graceful shutdown)
function closeDatabase() {
if (dbInstance) {
dbInstance.close((err) => {
@@ -47,18 +42,27 @@ function closeDatabase() {
}
}
// Best-effort ALTER TABLE: ignores "duplicate column" errors but logs others
function addColumnIfMissing(db, table, columnDef) {
db.run(`ALTER TABLE ${table} ADD COLUMN ${columnDef}`, (err) => {
if (err && !/duplicate column name/i.test(err.message)) {
console.error(`ALTER TABLE ${table} ADD ${columnDef}:`, err.message);
}
});
}
function initDatabase() {
const db = getDatabase();
db.serialize(() => {
// Anbieter (Providers) table
// Anbieter (Providers)
db.run(`CREATE TABLE IF NOT EXISTS anbieter (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
// Mitarbeiter-Prozentsätze pro Anbieter (can be changed retrospectively)
// Mitarbeiter-Prozentsätze pro Anbieter
db.run(`CREATE TABLE IF NOT EXISTS mitarbeiter_prozentsaetze (
id INTEGER PRIMARY KEY AUTOINCREMENT,
anbieter_id INTEGER NOT NULL,
@@ -70,7 +74,7 @@ function initDatabase() {
UNIQUE(anbieter_id, gueltig_ab)
)`);
// Tarife (Tariffs) table - simplified with fixed euro amounts only
// Tarife
db.run(`CREATE TABLE IF NOT EXISTS tarife (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
@@ -82,18 +86,15 @@ function initDatabase() {
FOREIGN KEY (anbieter_id) REFERENCES anbieter (id),
UNIQUE(name, anbieter_id)
)`);
addColumnIfMissing(db, 'tarife', 'mitarbeiter_provision DECIMAL(10,2) DEFAULT 0');
// Add mitarbeiter_provision column if it doesn't exist (for existing databases)
db.run(`ALTER TABLE tarife ADD COLUMN mitarbeiter_provision DECIMAL(10,2) DEFAULT 0`, (err) => {
// Ignore error if column already exists
});
// Provision rules table (tariff-specific)
// Tariff-specific commission rules. basis_euro: base amount for percent calculation.
db.run(`CREATE TABLE IF NOT EXISTS provisionsregeln (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tarif_id INTEGER NOT NULL,
provision_typ TEXT NOT NULL CHECK(provision_typ IN ('fix', 'prozent')),
betrag DECIMAL(10,2) NOT NULL,
basis_euro DECIMAL(10,2),
beschreibung TEXT,
verkaeufer_provision_typ TEXT CHECK(verkaeufer_provision_typ IN ('fix', 'prozent')),
verkaeufer_provision_betrag DECIMAL(10,2),
@@ -103,13 +104,15 @@ function initDatabase() {
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tarif_id) REFERENCES tarife (id)
)`);
addColumnIfMissing(db, 'provisionsregeln', 'basis_euro DECIMAL(10,2)');
// Global provider commission rules table
// Global provider commission rules
db.run(`CREATE TABLE IF NOT EXISTS anbieter_provisionsregeln (
id INTEGER PRIMARY KEY AUTOINCREMENT,
anbieter_id INTEGER NOT NULL,
provision_typ TEXT NOT NULL CHECK(provision_typ IN ('fix', 'prozent')),
betrag DECIMAL(10,2) NOT NULL,
basis_euro DECIMAL(10,2),
beschreibung TEXT,
verkaeufer_provision_typ TEXT CHECK(verkaeufer_provision_typ IN ('fix', 'prozent')),
verkaeufer_provision_betrag DECIMAL(10,2),
@@ -119,8 +122,9 @@ function initDatabase() {
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (anbieter_id) REFERENCES anbieter (id)
)`);
addColumnIfMissing(db, 'anbieter_provisionsregeln', 'basis_euro DECIMAL(10,2)');
// Schaltungen (Connections/Commissions) table
// Schaltungen
db.run(`CREATE TABLE IF NOT EXISTS schaltungen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
schaltungs_id TEXT NOT NULL UNIQUE,
@@ -139,16 +143,11 @@ function initDatabase() {
FOREIGN KEY (anbieter_id) REFERENCES anbieter (id),
FOREIGN KEY (tarif_id) REFERENCES tarife (id)
)`);
addColumnIfMissing(db, 'schaltungen', 'hardware_ek DECIMAL(10,2)');
addColumnIfMissing(db, 'schaltungen', 'kundenverkaufspreis_netto DECIMAL(10,2)');
// Add new hardware pricing columns if they don't exist (for existing databases)
db.run(`ALTER TABLE schaltungen ADD COLUMN hardware_ek DECIMAL(10,2)`, (err) => {
// Ignore error if column already exists
});
db.run(`ALTER TABLE schaltungen ADD COLUMN kundenverkaufspreis_netto DECIMAL(10,2)`, (err) => {
// Ignore error if column already exists
});
// Dokumente (Documents) table
// Dokumente (note: ON DELETE CASCADE only applied to new DBs - existing DBs
// handled via explicit DELETE in server.js)
db.run(`CREATE TABLE IF NOT EXISTS dokumente (
id INTEGER PRIMARY KEY AUTOINCREMENT,
schaltung_id INTEGER NOT NULL,
@@ -161,7 +160,7 @@ function initDatabase() {
FOREIGN KEY (schaltung_id) REFERENCES schaltungen (id) ON DELETE CASCADE
)`);
// Deleted Schaltungen tracking table
// Soft-deleted schaltungen
db.run(`CREATE TABLE IF NOT EXISTS geloeschte_schaltungen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
original_id INTEGER NOT NULL,
@@ -182,7 +181,66 @@ function initDatabase() {
geloescht_von TEXT DEFAULT 'System'
)`);
// Insert some default providers and tariffs for testing
// Tarifoptionen - schema matches production: anbieter_id required, tarif_id optional
// (NULL = anbieter-wide), unique per (anbieter_id, tarif_id, name).
db.run(`CREATE TABLE IF NOT EXISTS tarifoptionen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
anbieter_id INTEGER NOT NULL,
tarif_id INTEGER,
name TEXT NOT NULL,
code TEXT,
provision_euro DECIMAL(10,2) DEFAULT 0,
beschreibung TEXT,
aktiv BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (anbieter_id) REFERENCES anbieter (id),
FOREIGN KEY (tarif_id) REFERENCES tarife (id),
UNIQUE(anbieter_id, tarif_id, name)
)`);
// M:N variant kept for the /multi endpoints
db.run(`CREATE TABLE IF NOT EXISTS tarifoptionen_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
code TEXT,
provision_euro DECIMAL(10,2) DEFAULT 0,
beschreibung TEXT,
aktiv BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
db.run(`CREATE TABLE IF NOT EXISTS tarifoptionen_tarife (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tarifoption_id INTEGER NOT NULL,
tarif_id INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tarifoption_id) REFERENCES tarifoptionen_new (id) ON DELETE CASCADE,
FOREIGN KEY (tarif_id) REFERENCES tarife (id) ON DELETE CASCADE,
UNIQUE(tarifoption_id, tarif_id)
)`);
// Link table: which tarifoptionen are assigned to which schaltung
db.run(`CREATE TABLE IF NOT EXISTS schaltungen_tarifoptionen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
schaltung_id INTEGER NOT NULL,
tarifoption_id INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (schaltung_id) REFERENCES schaltungen (id) ON DELETE CASCADE,
FOREIGN KEY (tarifoption_id) REFERENCES tarifoptionen (id),
UNIQUE(schaltung_id, tarifoption_id)
)`);
// Users for authentication
db.run(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
// Seed data (idempotent)
db.run(`INSERT OR IGNORE INTO anbieter (name) VALUES
('Telekom'),
('Vodafone'),
@@ -201,132 +259,30 @@ function initDatabase() {
('o2 Free M', 3, 140.00, 'O2 Standard-Tarif'),
('o2 Free L', 3, 180.00, 'O2 Premium-Tarif')`);
// Insert mitarbeiter prozentsätze for each provider
db.run(`INSERT OR IGNORE INTO mitarbeiter_prozentsaetze (anbieter_id, prozentsatz, beschreibung) VALUES
(1, 8.00, 'Telekom Mitarbeiter-Provision'),
(2, 10.00, 'Vodafone/Freenet Mitarbeiter-Provision'),
(3, 7.50, 'O2 Mitarbeiter-Provision'),
(4, 9.00, '1&1 Mitarbeiter-Provision')`);
// Insert some example global provider commission rules
db.run(`INSERT OR IGNORE INTO anbieter_provisionsregeln (anbieter_id, provision_typ, betrag, beschreibung) VALUES
(3, 'fix', 20.00, 'Standard O2 Provision'),
(4, 'prozent', 12.00, '12% Standard 1&1 Provision')`);
// Tarifoptionen - updated structure for multiple tarif support
db.run(`CREATE TABLE IF NOT EXISTS tarifoptionen_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
code TEXT,
provision_euro DECIMAL(10,2) DEFAULT 0,
beschreibung TEXT,
aktiv BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
// Seed tarifoptionen (correct schema: anbieter_id required)
db.run(`INSERT OR IGNORE INTO tarifoptionen (anbieter_id, tarif_id, name, code, provision_euro, beschreibung) VALUES
(1, 1, 'Magenta Premium Option', 'MAG-PREM', 50.00, 'Premium-Zusatz für Telekom'),
(1, 1, 'Aktionscode Neukunde', 'NEU2024', 30.00, 'Neukunden-Bonus'),
(2, 4, '24 x 17€ MGP Rabatt GK A3958457', 'A3958457', -120.00, 'Vodafone Rabatt-Aktion'),
(2, 5, 'Red M Aktion Q1', 'RED-M-Q1', 25.00, 'Quartals-Aktion'),
(2, 6, 'Red L Premium Paket', 'RED-L-PREM', 35.00, 'Premium-Paket')`);
// Many-to-many relationship between tarifoptionen and tarife
db.run(`CREATE TABLE IF NOT EXISTS tarifoptionen_tarife (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tarifoption_id INTEGER NOT NULL,
tarif_id INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tarifoption_id) REFERENCES tarifoptionen_new (id) ON DELETE CASCADE,
FOREIGN KEY (tarif_id) REFERENCES tarife (id) ON DELETE CASCADE,
UNIQUE(tarifoption_id, tarif_id)
)`);
// Keep old table for migration compatibility
db.run(`CREATE TABLE IF NOT EXISTS tarifoptionen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tarif_id INTEGER NOT NULL,
name TEXT NOT NULL,
code TEXT,
provision_euro DECIMAL(10,2) DEFAULT 0,
beschreibung TEXT,
aktiv BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tarif_id) REFERENCES tarife (id),
UNIQUE(tarif_id, name)
)`);
// Schaltungen_Tarifoptionen (Many-to-Many relationship)
db.run(`CREATE TABLE IF NOT EXISTS schaltungen_tarifoptionen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
schaltung_id INTEGER NOT NULL,
tarifoption_id INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (schaltung_id) REFERENCES schaltungen (id) ON DELETE CASCADE,
FOREIGN KEY (tarifoption_id) REFERENCES tarifoptionen (id),
UNIQUE(schaltung_id, tarifoption_id)
)`);
// Insert example tarifoptionen with simplified euro amounts (old format)
db.run(`INSERT OR IGNORE INTO tarifoptionen (tarif_id, name, code, provision_euro, beschreibung) VALUES
(1, 'Magenta Premium Option', 'MAG-PREM', 50.00, 'Premium-Zusatz für Telekom'),
(1, 'Aktionscode Neukunde', 'NEU2024', 30.00, 'Neukunden-Bonus'),
(4, '24 x 17€ MGP Rabatt GK A3958457', 'A3958457', -120.00, 'Vodafone Rabatt-Aktion'),
(5, 'Red M Aktion Q1', 'RED-M-Q1', 25.00, 'Quartals-Aktion'),
(6, 'Red L Premium Paket', 'RED-L-PREM', 35.00, 'Premium-Paket')`);
// Migration function to move data from old to new structure
function migrateToNewTarifoptionen() {
db.serialize(() => {
// Check if migration is needed
db.get(`SELECT COUNT(*) as count FROM tarifoptionen_new`, (err, row) => {
if (err || row.count > 0) return; // Already migrated or error
console.log('Migrating tarifoptionen to new structure...');
// Get all unique tarifoptionen from old table
db.all(`SELECT DISTINCT name, code, provision_euro, beschreibung, aktiv, created_at FROM tarifoptionen`, (err, oldOptions) => {
if (err || !oldOptions.length) return;
oldOptions.forEach(option => {
// Insert unique tarifoption into new table
db.run(`INSERT OR IGNORE INTO tarifoptionen_new (name, code, provision_euro, beschreibung, aktiv, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
[option.name, option.code, option.provision_euro, option.beschreibung, option.aktiv, option.created_at],
function(err) {
if (err) return;
const newTarifoptionId = this.lastID;
// Link this tarifoption to all tarifs it was originally assigned to
db.all(`SELECT tarif_id FROM tarifoptionen WHERE name = ? AND code = ?`,
[option.name, option.code],
(err, tarifs) => {
if (err) return;
tarifs.forEach(tarif => {
db.run(`INSERT OR IGNORE INTO tarifoptionen_tarife (tarifoption_id, tarif_id) VALUES (?, ?)`,
[newTarifoptionId, tarif.tarif_id]);
});
});
});
});
});
});
});
}
// Run migration only if not already done
db.get(`SELECT COUNT(*) as count FROM tarifoptionen_new`, (err, row) => {
if (!err && row && row.count === 0) {
migrateToNewTarifoptionen();
}
});
// Keep existing individual tariff rules for backward compatibility
db.run(`INSERT OR IGNORE INTO provisionsregeln (tarif_id, provision_typ, betrag, beschreibung) VALUES
(1, 'fix', 25.00, 'Feste Provision für MagentaMobil S'),
(2, 'fix', 35.00, 'Feste Provision für MagentaMobil M'),
(3, 'fix', 45.00, 'Feste Provision für MagentaMobil L'),
(4, 'prozent', 15.00, '15% Provision für Red S'),
(5, 'prozent', 18.00, '18% Provision für Red M'),
(6, 'prozent', 20.00, '20% Provision für Red L')`);
(3, 'fix', 45.00, 'Feste Provision für MagentaMobil L')`);
});
// Enable foreign keys AFTER all tables and initial data are created
enableForeignKeys(db);
}
+44
View File
@@ -0,0 +1,44 @@
// Promise wrappers around sqlite3's callback API. Routes use these instead of raw callbacks.
const { getDatabase } = require('./database');
function all(sql, params = []) {
return new Promise((resolve, reject) => {
getDatabase().all(sql, params, (err, rows) => {
if (err) reject(err);
else resolve(rows);
});
});
}
function get(sql, params = []) {
return new Promise((resolve, reject) => {
getDatabase().get(sql, params, (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
}
function run(sql, params = []) {
return new Promise((resolve, reject) => {
getDatabase().run(sql, params, function (err) {
if (err) reject(err);
else resolve({ lastID: this.lastID, changes: this.changes });
});
});
}
// Executes work() inside BEGIN/COMMIT. Rolls back if work() throws.
async function transaction(work) {
await run('BEGIN');
try {
const result = await work();
await run('COMMIT');
return result;
} catch (err) {
try { await run('ROLLBACK'); } catch (_) { /* swallow */ }
throw err;
}
}
module.exports = { all, get, run, transaction };
+5
View File
@@ -0,0 +1,5 @@
// Wraps an async route handler so thrown errors flow to Express's error middleware
// instead of becoming unhandled rejections.
module.exports = function asyncHandler(fn) {
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
};
+17
View File
@@ -0,0 +1,17 @@
// Auth middleware: requireLogin gates API routes, requireAdmin restricts admin actions.
function requireLogin(req, res, next) {
if (req.session && req.session.userId) {
return next();
}
return res.status(401).json({ error: 'Nicht angemeldet' });
}
function requireAdmin(req, res, next) {
if (req.session && req.session.userId && req.session.isAdmin) {
return next();
}
return res.status(403).json({ error: 'Admin-Rechte erforderlich' });
}
module.exports = { requireLogin, requireAdmin };
+117
View File
@@ -8,8 +8,14 @@
"name": "provision-tracker",
"version": "1.0.0",
"dependencies": {
"bcryptjs": "^2.4.3",
"connect-sqlite3": "^0.9.13",
"cors": "^2.8.5",
"dotenv": "^17.4.2",
"express": "^4.18.2",
"express-rate-limit": "^7.4.0",
"express-session": "^1.18.0",
"helmet": "^7.1.0",
"multer": "^1.4.5-lts.1",
"sqlite3": "^5.1.6"
},
@@ -237,6 +243,11 @@
}
]
},
"node_modules/bcryptjs": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ=="
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -510,6 +521,17 @@
"typedarray": "^0.0.6"
}
},
"node_modules/connect-sqlite3": {
"version": "0.9.16",
"resolved": "https://registry.npmjs.org/connect-sqlite3/-/connect-sqlite3-0.9.16.tgz",
"integrity": "sha512-2gqo0QmcBBL8p8+eqpBETn7RgM/PaoKvpQGl8PfjEgwlr0VuMYNMxRJRrRCo3KR3fxMYeSsCw2tGNG0JKN9Nvg==",
"dependencies": {
"sqlite3": "^5.0.2"
},
"engines": {
"node": ">=0.4.x"
}
},
"node_modules/console-control-strings": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
@@ -626,6 +648,17 @@
"node": ">=8"
}
},
"node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -795,6 +828,55 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/express-session": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
"integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==",
"dependencies": {
"cookie": "~0.7.2",
"cookie-signature": "~1.0.7",
"debug": "~2.6.9",
"depd": "~2.0.0",
"on-headers": "~1.1.0",
"parseurl": "~1.3.3",
"safe-buffer": "~5.2.1",
"uid-safe": "~2.1.5"
},
"engines": {
"node": ">= 0.8.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/express-session/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express-session/node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
@@ -1036,6 +1118,14 @@
"node": ">= 0.4"
}
},
"node_modules/helmet": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz",
"integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/http-cache-semantics": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
@@ -1738,6 +1828,14 @@
"node": ">= 0.8"
}
},
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -1885,6 +1983,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/random-bytes": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -2509,6 +2615,17 @@
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="
},
"node_modules/uid-safe": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
"dependencies": {
"random-bytes": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
+11 -3
View File
@@ -5,13 +5,21 @@
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
"dev": "nodemon server.js",
"test": "node --test tests/",
"create-user": "node scripts/create-user.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"connect-sqlite3": "^0.9.13",
"cors": "^2.8.5",
"dotenv": "^17.4.2",
"express": "^4.18.2",
"sqlite3": "^5.1.6",
"express-rate-limit": "^7.4.0",
"express-session": "^1.18.0",
"helmet": "^7.1.0",
"multer": "^1.4.5-lts.1",
"cors": "^2.8.5"
"sqlite3": "^5.1.6"
},
"devDependencies": {
"nodemon": "^3.0.1"
+135 -167
View File
@@ -1,184 +1,152 @@
/**
* Commission Calculation Service
*
* This service handles all commission calculations for schaltungen.
* It implements a fallback hierarchy for determining commission rates:
* 1. Direct tariff commission (highest priority)
* 2. Tariff-specific rules
* 3. Global provider rules (fallback)
* 4. Tariff options (additive)
*/
// Commission calculation service. Single source of truth used by all provision endpoints.
//
// Hierarchy for shop commission (highest priority wins):
// 1. Tariff-specific rule (provisionsregeln, active, gueltig_ab <= schaltungs_datum)
// 2. Global provider rule (anbieter_provisionsregeln, active, gueltig_ab <= schaltungs_datum)
// 3. Direct tariff amount (tarife.provision_euro)
//
// For type='prozent' the rule's basis_euro field is the base amount the percentage
// applies to. If basis_euro is missing on a percent rule, the contribution is 0
// (rule is effectively unusable - caller should validate at write time).
//
// Tarifoptionen are added on top (signed: rebates are negative).
// Hardware logic: when hardware is set, shop = shop - hardware_ek + kundenverkaufspreis_netto.
//
// Employee commission follows the same hierarchy but uses verkaeufer_* fields.
// For type='prozent' the base is the computed shop commission.
/**
* Calculate shop commission based on hierarchy
* @param {Object} schaltungData - Data from database including tariff and provider info
* @param {Array} tarifoptionen - Selected tariff options for this schaltung
* @returns {Object} Calculation result with amount, type, description, and source
*/
function calculateShopProvision(schaltungData, tarifoptionen) {
let provision_typ = null;
let provision_betrag = null;
let provision_beschreibung = null;
let berechnete_provision = 0;
let quelle = 'Keine Shop-Provision definiert';
// Priority 1: Direct tariff commission
if (schaltungData.tarif_provision_typ && schaltungData.tarif_provision_betrag) {
provision_typ = schaltungData.tarif_provision_typ;
provision_betrag = schaltungData.tarif_provision_betrag;
provision_beschreibung = schaltungData.tarif_provision_beschreibung;
quelle = 'Direkte Tarif-Provision';
}
// Priority 2: Tariff-specific rule
else if (schaltungData.regel_provision_typ && schaltungData.regel_provision_betrag) {
provision_typ = schaltungData.regel_provision_typ;
provision_betrag = schaltungData.regel_provision_betrag;
provision_beschreibung = schaltungData.regel_provision_beschreibung;
quelle = 'Tarif-spezifische Regel';
}
// Priority 3: Global provider rule as fallback
else if (schaltungData.global_provision_typ && schaltungData.global_provision_betrag) {
provision_typ = schaltungData.global_provision_typ;
provision_betrag = schaltungData.global_provision_betrag;
provision_beschreibung = schaltungData.global_provision_beschreibung;
quelle = 'Globale Anbieter-Regel';
}
// Calculate base commission
if (provision_typ && provision_betrag) {
if (provision_typ === 'fix') {
berechnete_provision = parseFloat(provision_betrag);
} else if (provision_typ === 'prozent') {
const basisBetrag = 100; // This could come from tariff table
berechnete_provision = (parseFloat(provision_betrag) / 100) * basisBetrag;
}
}
// Add tarifoptionen to commission
let tarifoptionen_provision = 0;
let verwendete_tarifoptionen = [];
if (tarifoptionen && tarifoptionen.length > 0) {
tarifoptionen.forEach(option => {
if (option.provision_betrag && option.provision_betrag > 0) {
tarifoptionen_provision += parseFloat(option.provision_betrag);
verwendete_tarifoptionen.push(option.name);
}
});
}
berechnete_provision += tarifoptionen_provision;
// Update description if tarifoptionen were added
if (tarifoptionen_provision > 0) {
const tarifoptionen_text = verwendete_tarifoptionen.join(', ');
provision_beschreibung = provision_beschreibung
? `${provision_beschreibung} + Tarifoptionen: ${tarifoptionen_text}`
: `Tarifoptionen: ${tarifoptionen_text}`;
}
return {
typ: provision_typ,
betrag: provision_betrag,
beschreibung: provision_beschreibung,
berechnete_provision: berechnete_provision,
quelle: quelle,
tarifoptionen_provision: tarifoptionen_provision
};
function num(value) {
const n = parseFloat(value);
return Number.isFinite(n) ? n : 0;
}
/**
* Calculate employee commission based on hierarchy
* @param {Object} schaltungData - Data from database including tariff and provider info
* @param {number} shopProvision - Calculated shop commission (for percentage calculations)
* @returns {Object} Calculation result with amount, type, description, and source
*/
function calculateMitarbeiterProvision(schaltungData, shopProvision) {
let provision_typ = null;
let provision_betrag = null;
let provision_beschreibung = null;
let berechnete_provision = 0;
let quelle = 'Keine Mitarbeiter-Provision definiert';
function pickRule(context) {
// Returns the active rule that should apply, or null.
// context: { tarifRule, anbieterRule, tarif }
if (context.tarifRule) {
return { source: 'Tarif-spezifische Regel', rule: context.tarifRule };
}
if (context.anbieterRule) {
return { source: 'Globale Anbieter-Regel', rule: context.anbieterRule };
}
if (context.tarif && context.tarif.provision_euro != null && num(context.tarif.provision_euro) !== 0) {
return {
source: 'Direkte Tarif-Provision',
rule: {
provision_typ: 'fix',
betrag: num(context.tarif.provision_euro),
basis_euro: null,
beschreibung: context.tarif.beschreibung || null,
verkaeufer_provision_typ: null,
verkaeufer_provision_betrag: null,
verkaeufer_provision_beschreibung: null,
},
};
}
return null;
}
// Priority 1: Direct tariff employee commission
if (schaltungData.tarif_verkaeufer_provision_typ && schaltungData.tarif_verkaeufer_provision_betrag) {
provision_typ = schaltungData.tarif_verkaeufer_provision_typ;
provision_betrag = schaltungData.tarif_verkaeufer_provision_betrag;
provision_beschreibung = schaltungData.tarif_verkaeufer_provision_beschreibung;
quelle = 'Direkte Tarif-Verkäufer-Provision';
function applyShopRule(rule) {
if (!rule) return 0;
if (rule.provision_typ === 'fix') {
return num(rule.betrag);
}
// Priority 2: Tariff-specific rule employee commission
else if (schaltungData.regel_verkaeufer_provision_typ && schaltungData.regel_verkaeufer_provision_betrag) {
provision_typ = schaltungData.regel_verkaeufer_provision_typ;
provision_betrag = schaltungData.regel_verkaeufer_provision_betrag;
provision_beschreibung = schaltungData.regel_verkaeufer_provision_beschreibung;
quelle = 'Tarif-spezifische Verkäufer-Regel';
if (rule.provision_typ === 'prozent') {
const basis = num(rule.basis_euro);
return (num(rule.betrag) / 100) * basis;
}
// Priority 3: Global provider rule employee commission
else if (schaltungData.global_verkaeufer_provision_typ && schaltungData.global_verkaeufer_provision_betrag) {
provision_typ = schaltungData.global_verkaeufer_provision_typ;
provision_betrag = schaltungData.global_verkaeufer_provision_betrag;
provision_beschreibung = schaltungData.global_verkaeufer_provision_beschreibung;
quelle = 'Globale Anbieter-Verkäufer-Regel';
return 0;
}
function applyEmployeeRule(rule, shopProvision) {
if (!rule) return 0;
if (rule.verkaeufer_provision_typ === 'fix') {
return num(rule.verkaeufer_provision_betrag);
}
if (rule.verkaeufer_provision_typ === 'prozent') {
return (num(rule.verkaeufer_provision_betrag) / 100) * shopProvision;
}
return 0;
}
function sumTarifoptionen(tarifoptionen) {
if (!Array.isArray(tarifoptionen)) return { sum: 0, names: [] };
let sum = 0;
const names = [];
for (const opt of tarifoptionen) {
const v = num(opt.provision_euro);
if (v !== 0) {
sum += v;
names.push(opt.name);
}
}
return { sum, names };
}
// Main entry. Inputs are plain data (already loaded from DB by the route).
// `schaltung` is the schaltungen row (joined with tarif basics).
// `tarif` is the full tarif row.
// `tarifRule` is the active provisionsregeln row (or null).
// `anbieterRule` is the active anbieter_provisionsregeln row (or null).
// `tarifoptionen` is an array of tarifoption rows linked to this schaltung.
// `mitarbeiterProzentsatz` is a number (0-100) — used only when there is no
// employee-specific rule and the tariff defines mitarbeiter_provision.
function calculate({ schaltung, tarif, tarifRule, anbieterRule, tarifoptionen, mitarbeiterProzentsatz }) {
const picked = pickRule({ tarifRule, anbieterRule, tarif });
const ruleSource = picked ? picked.source : 'Keine Shop-Provision definiert';
const rule = picked ? picked.rule : null;
let shopProvision = applyShopRule(rule);
const { sum: tarifoptionenSum, names: tarifoptionenNames } = sumTarifoptionen(tarifoptionen);
shopProvision += tarifoptionenSum;
let hardwareGewinn = 0;
if (schaltung && schaltung.hardware && String(schaltung.hardware).trim() !== '') {
hardwareGewinn = num(schaltung.kundenverkaufspreis_netto) - num(schaltung.hardware_ek);
shopProvision += hardwareGewinn;
}
// Calculate employee commission
if (provision_typ && provision_betrag) {
if (provision_typ === 'fix') {
berechnete_provision = parseFloat(provision_betrag);
} else if (provision_typ === 'prozent') {
// If percentage, calculate based on shop commission
berechnete_provision = (parseFloat(provision_betrag) / 100) * shopProvision;
let employeeProvision = applyEmployeeRule(rule, shopProvision);
let employeeSource = picked ? `${picked.source} (Verkäufer)` : 'Keine Mitarbeiter-Provision definiert';
// Fallback: if no rule defines an employee commission, use the per-anbieter
// mitarbeiter_prozentsatz against the computed shop commission.
if (employeeProvision === 0 && (!rule || !rule.verkaeufer_provision_typ)) {
const p = num(mitarbeiterProzentsatz);
if (p !== 0) {
employeeProvision = (p / 100) * shopProvision;
employeeSource = `${p}% Mitarbeiter-Anteil von ${shopProvision.toFixed(2)}€`;
}
}
return {
typ: provision_typ,
betrag: provision_betrag,
beschreibung: provision_beschreibung,
berechnete_provision: berechnete_provision,
quelle: quelle
shop: {
betrag: round2(shopProvision),
quelle: ruleSource,
typ: rule ? rule.provision_typ : null,
regel_betrag: rule ? num(rule.betrag) : null,
basis_euro: rule ? (rule.basis_euro != null ? num(rule.basis_euro) : null) : null,
beschreibung: rule ? rule.beschreibung : null,
},
mitarbeiter: {
betrag: round2(employeeProvision),
quelle: employeeSource,
typ: rule ? rule.verkaeufer_provision_typ : null,
regel_betrag: rule ? (rule.verkaeufer_provision_betrag != null ? num(rule.verkaeufer_provision_betrag) : null) : null,
beschreibung: rule ? rule.verkaeufer_provision_beschreibung : null,
prozentsatz: num(mitarbeiterProzentsatz),
},
tarifoptionen: {
betrag: round2(tarifoptionenSum),
namen: tarifoptionenNames,
},
hardware_gewinn: round2(hardwareGewinn),
};
}
/**
* Main function to calculate all commissions for a schaltung
* @param {Object} schaltungData - Complete schaltung data with tariff and provider info
* @param {Array} tarifoptionen - Selected tariff options
* @returns {Object} Complete commission calculation result
*/
function calculateProvision(schaltungData, tarifoptionen) {
const shopResult = calculateShopProvision(schaltungData, tarifoptionen);
const mitarbeiterResult = calculateMitarbeiterProvision(schaltungData, shopResult.berechnete_provision);
return {
schaltung: {
id: schaltungData.id,
schaltungs_id: schaltungData.schaltungs_id,
tarif: schaltungData.tarif_name,
anbieter: schaltungData.anbieter_name
},
shop_provision: {
typ: shopResult.typ,
betrag: shopResult.betrag,
beschreibung: shopResult.beschreibung,
berechnete_provision: shopResult.berechnete_provision,
quelle: shopResult.quelle
},
mitarbeiter_provision: {
typ: mitarbeiterResult.typ,
betrag: mitarbeiterResult.betrag,
beschreibung: mitarbeiterResult.beschreibung,
berechnete_provision: mitarbeiterResult.berechnete_provision,
quelle: mitarbeiterResult.quelle,
prozentsatz: schaltungData.mitarbeiter_prozentsatz || 0
},
tarifoptionen: tarifoptionen || [],
tarifoptionen_provision: shopResult.tarifoptionen_provision
};
function round2(n) {
return Math.round(n * 100) / 100;
}
module.exports = {
calculateProvision,
calculateShopProvision,
calculateMitarbeiterProvision
};
module.exports = { calculate, num, round2 };
+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;
}
});
})();
+273 -146
View File
@@ -1,9 +1,36 @@
// 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();
@@ -19,6 +46,33 @@ document.addEventListener('DOMContentLoaded', function() {
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,9 +147,15 @@ function showTab(tabName) {
// API Helper functions
async function apiRequest(url, options = {}) {
try {
const response = await fetch(url, options);
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');
}
// Check if response has content
const contentType = response.headers.get('content-type');
let data = null;
@@ -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>
`;
@@ -430,10 +490,15 @@ function displayUploadedFile(file) {
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>
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${a.id}</td>
<td>${a.name}</td>
<td>${createdAt}</td>
<td>${escapeHtml(a.name)}</td>
<td>${escapeHtml(createdAt)}</td>
<td>
<button onclick="deleteAnbieter(${a.id}, '${a.name}')" class="btn-danger">Löschen</button>
<button data-anbieter-action="delete" data-id="${a.id}" data-name="${escapeHtml(a.name)}" class="btn-danger">Löschen</button>
</td>
</tr>
`;
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}">
const tr = document.createElement('tr');
tr.id = `tarif-item-${t.id}`;
tr.innerHTML = `
<td>${t.id}</td>
<td>${t.anbieter_name || 'Unbekannt'}</td>
<td>${t.name}</td>
<td>${provision}</td>
<td>${t.beschreibung || '-'}</td>
<td>${escapeHtml(t.anbieter_name || 'Unbekannt')}</td>
<td>${escapeHtml(t.name)}</td>
<td>${escapeHtml(provision)}</td>
<td>${escapeHtml(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>
<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>
</tr>
`;
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,7 +1118,9 @@ 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 => {
@@ -1040,13 +1132,17 @@ async function loadProvisionsregelnAdmin() {
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,7 +1262,9 @@ 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 => {
@@ -1174,7 +1272,6 @@ async function loadGlobalProvisionsregelnAdmin() {
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'
@@ -1189,13 +1286,17 @@ async function loadGlobalProvisionsregelnAdmin() {
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);
});
}
@@ -2012,13 +2122,13 @@ function updateProvisionenTable(schaltungen, totalShopProvision, totalMitarbeite
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>
const tr = document.createElement('tr');
tr.innerHTML = `
<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>${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 onclick="editTarifoption(${option.id})" class="btn-success" style="margin-right: 5px;">Bearbeiten</button>
<button onclick="deleteTarifoption(${option.id})" class="btn-danger">Löschen</button>
<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>
</tr>
`;
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,7 +3070,7 @@ 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>
@@ -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,8 +3205,8 @@ 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">
@@ -3093,9 +3217,9 @@ async function loadTarifProvisionsAdmin() {
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);
}
+81
View File
@@ -0,0 +1,81 @@
const express = require('express');
const db = require('../db');
const asyncHandler = require('../middleware/asyncHandler');
const router = express.Router();
router.get('/', asyncHandler(async (req, res) => {
const rows = await db.all('SELECT * FROM anbieter ORDER BY name');
res.json(rows);
}));
router.post('/', asyncHandler(async (req, res) => {
const { name } = req.body || {};
if (!name || !name.trim()) {
return res.status(400).json({ error: 'Name ist erforderlich' });
}
try {
const result = await db.run('INSERT INTO anbieter (name) VALUES (?)', [name.trim()]);
res.json({ id: result.lastID, name: name.trim() });
} catch (err) {
if (/UNIQUE/.test(err.message)) {
return res.status(400).json({ error: 'Anbieter mit diesem Namen existiert bereits' });
}
throw err;
}
}));
router.delete('/:id', asyncHandler(async (req, res) => {
const { id } = req.params;
const [tariffeRow, schaltungenRow] = await Promise.all([
db.get('SELECT COUNT(*) AS n FROM tarife WHERE anbieter_id = ?', [id]),
db.get('SELECT COUNT(*) AS n FROM schaltungen WHERE anbieter_id = ?', [id]),
]);
if (tariffeRow.n + schaltungenRow.n > 0) {
const reasons = [];
if (tariffeRow.n > 0) reasons.push(`${tariffeRow.n} Tarif(e)`);
if (schaltungenRow.n > 0) reasons.push(`${schaltungenRow.n} Schaltung(en)`);
return res.status(400).json({
error: `Anbieter kann nicht gelöscht werden: ${reasons.join(', ')} verwenden diesen Anbieter`,
});
}
const deletedRules = await db.run('DELETE FROM anbieter_provisionsregeln WHERE anbieter_id = ?', [id]);
const result = await db.run('DELETE FROM anbieter WHERE id = ?', [id]);
if (result.changes === 0) {
return res.status(404).json({ error: 'Anbieter nicht gefunden' });
}
let message = 'Anbieter erfolgreich gelöscht';
if (deletedRules.changes > 0) {
message += ` (${deletedRules.changes} Provisionsregel(n) wurden mit gelöscht)`;
}
res.json({ message });
}));
// Global commission rules for this provider
router.get('/:id/provisionsregeln', asyncHandler(async (req, res) => {
const { id } = req.params;
const rows = await db.all(
`SELECT apr.*, a.name AS anbieter_name
FROM anbieter_provisionsregeln apr
JOIN anbieter a ON apr.anbieter_id = a.id
WHERE apr.anbieter_id = ? AND apr.aktiv = 1
ORDER BY apr.gueltig_ab DESC`,
[id]
);
res.json(rows);
}));
// Mitarbeiter-Prozentsätze for this provider
router.get('/:id/mitarbeiter-prozentsaetze', asyncHandler(async (req, res) => {
const { id } = req.params;
const rows = await db.all(
'SELECT * FROM mitarbeiter_prozentsaetze WHERE anbieter_id = ? ORDER BY gueltig_ab DESC',
[id]
);
res.json(rows);
}));
module.exports = router;
+120
View File
@@ -0,0 +1,120 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const rateLimit = require('express-rate-limit');
const db = require('../db');
const asyncHandler = require('../middleware/asyncHandler');
const { requireLogin } = require('../middleware/auth');
const router = express.Router();
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 20,
message: { error: 'Zu viele Anmeldeversuche, bitte später erneut versuchen.' },
standardHeaders: true,
legacyHeaders: false,
});
// Whether the system needs initial setup (no users yet).
router.get('/setup-required', asyncHandler(async (req, res) => {
const row = await db.get('SELECT COUNT(*) AS n FROM users');
res.json({ setupRequired: row.n === 0 });
}));
// One-shot setup: create the first admin user. Only works when users table is empty.
router.post('/setup', asyncHandler(async (req, res) => {
const existing = await db.get('SELECT COUNT(*) AS n FROM users');
if (existing.n > 0) {
return res.status(403).json({ error: 'Setup bereits abgeschlossen' });
}
const { username, password, displayName } = req.body || {};
if (!username || !password) {
return res.status(400).json({ error: 'Username und Passwort erforderlich' });
}
if (password.length < 8) {
return res.status(400).json({ error: 'Passwort muss mindestens 8 Zeichen lang sein' });
}
const hash = await bcrypt.hash(password, 10);
const result = await db.run(
'INSERT INTO users (username, password_hash, display_name, is_admin) VALUES (?, ?, ?, 1)',
[username, hash, displayName || username]
);
req.session.userId = result.lastID;
req.session.username = username;
req.session.isAdmin = true;
res.json({ id: result.lastID, username, isAdmin: true });
}));
router.post('/login', loginLimiter, asyncHandler(async (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) {
return res.status(400).json({ error: 'Username und Passwort erforderlich' });
}
const user = await db.get(
'SELECT id, username, password_hash, display_name, is_admin FROM users WHERE username = ?',
[username]
);
if (!user) {
return res.status(401).json({ error: 'Ungültige Anmeldedaten' });
}
const ok = await bcrypt.compare(password, user.password_hash);
if (!ok) {
return res.status(401).json({ error: 'Ungültige Anmeldedaten' });
}
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Session-Fehler' });
req.session.userId = user.id;
req.session.username = user.username;
req.session.isAdmin = !!user.is_admin;
res.json({
id: user.id,
username: user.username,
displayName: user.display_name,
isAdmin: !!user.is_admin,
});
});
}));
router.post('/logout', (req, res) => {
if (!req.session) return res.json({ ok: true });
req.session.destroy(() => {
res.clearCookie('connect.sid');
res.json({ ok: true });
});
});
router.get('/me', requireLogin, asyncHandler(async (req, res) => {
const user = await db.get(
'SELECT id, username, display_name, is_admin FROM users WHERE id = ?',
[req.session.userId]
);
if (!user) {
req.session.destroy(() => {});
return res.status(401).json({ error: 'Benutzer nicht gefunden' });
}
res.json({
id: user.id,
username: user.username,
displayName: user.display_name,
isAdmin: !!user.is_admin,
});
}));
router.post('/change-password', requireLogin, asyncHandler(async (req, res) => {
const { oldPassword, newPassword } = req.body || {};
if (!oldPassword || !newPassword) {
return res.status(400).json({ error: 'Altes und neues Passwort erforderlich' });
}
if (newPassword.length < 8) {
return res.status(400).json({ error: 'Neues Passwort muss mindestens 8 Zeichen lang sein' });
}
const user = await db.get('SELECT password_hash FROM users WHERE id = ?', [req.session.userId]);
if (!user) return res.status(401).json({ error: 'Benutzer nicht gefunden' });
const ok = await bcrypt.compare(oldPassword, user.password_hash);
if (!ok) return res.status(401).json({ error: 'Altes Passwort falsch' });
const newHash = await bcrypt.hash(newPassword, 10);
await db.run('UPDATE users SET password_hash = ? WHERE id = ?', [newHash, req.session.userId]);
res.json({ ok: true });
}));
module.exports = router;
+105
View File
@@ -0,0 +1,105 @@
const express = require('express');
const fs = require('fs');
const path = require('path');
const multer = require('multer');
const db = require('../db');
const asyncHandler = require('../middleware/asyncHandler');
const router = express.Router();
const ALLOWED_FILE_TYPES = {
'application/pdf': ['.pdf'],
'image/jpeg': ['.jpg', '.jpeg'],
'image/png': ['.png'],
'application/msword': ['.doc'],
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
};
function fileFilter(req, file, cb) {
const allowedExtensions = Object.values(ALLOWED_FILE_TYPES).flat();
const ext = path.extname(file.originalname).toLowerCase();
if (allowedExtensions.includes(ext) && ALLOWED_FILE_TYPES[file.mimetype]) {
cb(null, true);
} else {
cb(new Error(`Dateityp nicht erlaubt: ${ext}. Erlaubt sind: PDF, JPG, PNG, DOC, DOCX`), false);
}
}
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, 'uploads/'),
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, uniqueSuffix + path.extname(file.originalname));
},
});
const upload = multer({ storage, fileFilter, limits: { fileSize: 10 * 1024 * 1024 } });
// Upload document for a schaltung (mounted as /api/schaltungen/:id/dokumente by server.js)
router.post('/schaltungen/:id/dokumente', upload.single('document'), asyncHandler(async (req, res) => {
const schaltung_id = req.params.id;
const file = req.file;
if (!file) return res.status(400).json({ error: 'Keine Datei hochgeladen' });
const cleanup = () => {
fs.unlink(file.path, (e) => {
if (e) console.error('Cleanup error:', e.message);
});
};
const schaltung = await db.get('SELECT id FROM schaltungen WHERE id = ?', [schaltung_id]);
if (!schaltung) {
cleanup();
return res.status(404).json({ error: 'Schaltung nicht gefunden' });
}
try {
const result = await db.run(
`INSERT INTO dokumente (schaltung_id, filename, original_filename, filepath, filesize, mimetype)
VALUES (?, ?, ?, ?, ?, ?)`,
[schaltung_id, file.filename, file.originalname, file.path, file.size, file.mimetype]
);
res.json({ id: result.lastID, message: 'Dokument erfolgreich hochgeladen', filename: file.originalname });
} catch (err) {
cleanup();
throw err;
}
}));
// List documents for a schaltung
router.get('/schaltungen/:id/dokumente', asyncHandler(async (req, res) => {
const rows = await db.all('SELECT * FROM dokumente WHERE schaltung_id = ?', [req.params.id]);
res.json(rows);
}));
// Download single document
router.get('/dokumente/:id/download', asyncHandler(async (req, res) => {
const row = await db.get('SELECT * FROM dokumente WHERE id = ?', [req.params.id]);
if (!row) return res.status(404).json({ error: 'Dokument nicht gefunden' });
const filepath = req.app.locals.safeUploadPath(row.filepath);
if (!filepath) {
console.error(`Path traversal attempt or invalid path for document ${row.id}: ${row.filepath}`);
return res.status(400).json({ error: 'Ungültiger Dateipfad' });
}
res.download(filepath, row.original_filename);
}));
// Delete document
router.delete('/dokumente/:id', asyncHandler(async (req, res) => {
const row = await db.get('SELECT * FROM dokumente WHERE id = ?', [req.params.id]);
if (!row) return res.status(404).json({ error: 'Dokument nicht gefunden' });
await db.run('DELETE FROM dokumente WHERE id = ?', [req.params.id]);
const filepath = req.app.locals.safeUploadPath(row.filepath);
if (filepath) {
fs.unlink(filepath, (err) => {
if (err) console.error('Fehler beim Löschen der Datei:', err.message);
});
} else {
console.error(`Skipping file delete - invalid path: ${row.filepath}`);
}
res.json({ message: 'Dokument erfolgreich gelöscht' });
}));
module.exports = router;
+34
View File
@@ -0,0 +1,34 @@
const express = require('express');
const db = require('../db');
const asyncHandler = require('../middleware/asyncHandler');
const router = express.Router();
router.post('/', asyncHandler(async (req, res) => {
const { anbieter_id, prozentsatz, gueltig_ab, beschreibung } = req.body || {};
if (!anbieter_id) return res.status(400).json({ error: 'Anbieter ist erforderlich' });
const p = parseFloat(prozentsatz);
if (!Number.isFinite(p) || p < 0 || p > 100) {
return res.status(400).json({ error: 'Prozentsatz muss zwischen 0 und 100 liegen' });
}
try {
const result = await db.run(
'INSERT INTO mitarbeiter_prozentsaetze (anbieter_id, prozentsatz, gueltig_ab, beschreibung) VALUES (?, ?, ?, ?)',
[anbieter_id, p, gueltig_ab || new Date().toISOString().split('T')[0], beschreibung || null]
);
res.json({ id: result.lastID, message: 'Mitarbeiter-Prozentsatz erfolgreich erstellt' });
} catch (err) {
if (/UNIQUE/.test(err.message)) {
return res.status(400).json({ error: 'Ein Prozentsatz für dieses Datum existiert bereits für diesen Anbieter' });
}
throw err;
}
}));
router.delete('/:id', asyncHandler(async (req, res) => {
const result = await db.run('DELETE FROM mitarbeiter_prozentsaetze WHERE id = ?', [req.params.id]);
if (result.changes === 0) return res.status(404).json({ error: 'Mitarbeiter-Prozentsatz nicht gefunden' });
res.json({ message: 'Mitarbeiter-Prozentsatz erfolgreich gelöscht' });
}));
module.exports = router;
+151
View File
@@ -0,0 +1,151 @@
const express = require('express');
const db = require('../db');
const asyncHandler = require('../middleware/asyncHandler');
const provisionService = require('../provisionService');
const router = express.Router();
// Load all inputs needed by provisionService.calculate for one schaltung.
async function loadProvisionContext(schaltungId) {
const schaltung = await db.get(
`SELECT s.*,
t.name AS tarif_name, t.provision_euro AS tarif_provision_euro,
t.mitarbeiter_provision AS tarif_mitarbeiter_provision,
t.beschreibung AS tarif_beschreibung,
a.name AS anbieter_name
FROM schaltungen s
JOIN tarife t ON s.tarif_id = t.id
JOIN anbieter a ON s.anbieter_id = a.id
WHERE s.id = ?`,
[schaltungId]
);
if (!schaltung) return null;
const tarif = {
id: schaltung.tarif_id,
name: schaltung.tarif_name,
provision_euro: schaltung.tarif_provision_euro,
mitarbeiter_provision: schaltung.tarif_mitarbeiter_provision,
beschreibung: schaltung.tarif_beschreibung,
};
const tarifRule = await db.get(
`SELECT * FROM provisionsregeln
WHERE tarif_id = ? AND aktiv = 1 AND gueltig_ab <= ?
ORDER BY gueltig_ab DESC LIMIT 1`,
[schaltung.tarif_id, schaltung.schaltungs_datum]
);
const anbieterRule = await db.get(
`SELECT * FROM anbieter_provisionsregeln
WHERE anbieter_id = ? AND aktiv = 1 AND gueltig_ab <= ?
ORDER BY gueltig_ab DESC LIMIT 1`,
[schaltung.anbieter_id, schaltung.schaltungs_datum]
);
const tarifoptionen = await db.all(
`SELECT t.*
FROM tarifoptionen t
JOIN schaltungen_tarifoptionen st ON t.id = st.tarifoption_id
WHERE st.schaltung_id = ?`,
[schaltungId]
);
const mp = await db.get(
`SELECT prozentsatz FROM mitarbeiter_prozentsaetze
WHERE anbieter_id = ? AND gueltig_ab <= ?
ORDER BY gueltig_ab DESC LIMIT 1`,
[schaltung.anbieter_id, schaltung.schaltungs_datum]
);
return {
schaltung,
tarif,
tarifRule: tarifRule || null,
anbieterRule: anbieterRule || null,
tarifoptionen,
mitarbeiterProzentsatz: mp ? mp.prozentsatz : 0,
};
}
// Provision for a single schaltung
router.get('/schaltungen/:id/provision', asyncHandler(async (req, res) => {
const ctx = await loadProvisionContext(req.params.id);
if (!ctx) return res.status(404).json({ error: 'Schaltung nicht gefunden' });
const result = provisionService.calculate(ctx);
res.json({
id: ctx.schaltung.id,
schaltungs_id: ctx.schaltung.schaltungs_id,
schaltungs_datum: ctx.schaltung.schaltungs_datum,
vorname: ctx.schaltung.vorname,
nachname: ctx.schaltung.nachname,
firma: ctx.schaltung.firma,
tarif_name: ctx.tarif.name,
anbieter_name: ctx.schaltung.anbieter_name,
shop_provision: result.shop,
mitarbeiter_provision: result.mitarbeiter,
tarifoptionen: result.tarifoptionen,
hardware_gewinn: result.hardware_gewinn,
// Legacy flat fields for older frontend code
berechnete_shop_provision: result.shop.betrag.toFixed(2),
berechnete_mitarbeiter_provision: result.mitarbeiter.betrag.toFixed(2),
shop_quelle: result.shop.quelle,
mitarbeiter_quelle: result.mitarbeiter.quelle,
});
}));
// Aggregated commission overview across all schaltungen
router.get('/provisionen', asyncHandler(async (req, res) => {
const schaltungen = await db.all('SELECT id FROM schaltungen ORDER BY schaltungs_datum DESC');
const processed = [];
let totalShop = 0;
let totalMitarbeiter = 0;
for (const { id } of schaltungen) {
const ctx = await loadProvisionContext(id);
if (!ctx) continue;
const result = provisionService.calculate(ctx);
totalShop += result.shop.betrag;
totalMitarbeiter += result.mitarbeiter.betrag;
processed.push({
id: ctx.schaltung.id,
schaltungs_id: ctx.schaltung.schaltungs_id,
eingabe_datum: ctx.schaltung.eingabe_datum,
schaltungs_datum: ctx.schaltung.schaltungs_datum,
vorname: ctx.schaltung.vorname,
nachname: ctx.schaltung.nachname,
firma: ctx.schaltung.firma,
hardware: ctx.schaltung.hardware,
hardware_ek: ctx.schaltung.hardware_ek,
kundenverkaufspreis_netto: ctx.schaltung.kundenverkaufspreis_netto,
tarif_name: ctx.tarif.name,
anbieter_name: ctx.schaltung.anbieter_name,
shop_provision_euro: result.shop.betrag,
berechnete_shop_provision: result.shop.betrag.toFixed(2),
mitarbeiter_prozentsatz: result.mitarbeiter.prozentsatz,
berechnete_mitarbeiter_provision: result.mitarbeiter.betrag.toFixed(2),
shop_quelle: result.shop.quelle,
mitarbeiter_quelle: result.mitarbeiter.quelle,
tarifoptionen_provision: result.tarifoptionen.betrag,
hardware_gewinn: result.hardware_gewinn,
});
}
res.json({
schaltungen: processed,
total_provision: totalShop.toFixed(2),
total_shop_provision: totalShop.toFixed(2),
total_mitarbeiter_provision: totalMitarbeiter.toFixed(2),
total_gesamt_provision: (totalShop + totalMitarbeiter).toFixed(2),
});
}));
// Deleted schaltungen list
router.get('/geloeschte-schaltungen', asyncHandler(async (req, res) => {
const rows = await db.all('SELECT * FROM geloeschte_schaltungen ORDER BY geloescht_am DESC');
res.json(rows);
}));
module.exports = router;
+172
View File
@@ -0,0 +1,172 @@
const express = require('express');
const db = require('../db');
const asyncHandler = require('../middleware/asyncHandler');
const router = express.Router();
// Validates that a percent rule has a basis_euro >= 0. Returns error string or null.
function validateRule({ provision_typ, betrag, basis_euro, verkaeufer_provision_typ, verkaeufer_provision_betrag }) {
if (!['fix', 'prozent'].includes(provision_typ)) {
return 'Ungültiger Provisionstyp';
}
const b = parseFloat(betrag);
if (!Number.isFinite(b)) return 'Betrag muss eine Zahl sein';
if (provision_typ === 'prozent' && (b < 0 || b > 100)) {
return 'Prozentual muss zwischen 0 und 100 sein';
}
if (provision_typ === 'fix' && b < 0) {
return 'Fixer Betrag kann nicht negativ sein';
}
if (provision_typ === 'prozent') {
if (basis_euro === undefined || basis_euro === null || basis_euro === '') {
return 'Prozent-Provision benötigt einen Basis-Betrag (basis_euro)';
}
const bb = parseFloat(basis_euro);
if (!Number.isFinite(bb) || bb < 0) {
return 'Basis-Betrag muss eine positive Zahl sein';
}
}
if (verkaeufer_provision_typ) {
if (!['fix', 'prozent'].includes(verkaeufer_provision_typ)) {
return 'Ungültiger Verkäufer-Provisionstyp';
}
const vb = parseFloat(verkaeufer_provision_betrag);
if (!Number.isFinite(vb)) return 'Verkäufer-Betrag muss eine Zahl sein';
if (verkaeufer_provision_typ === 'prozent' && (vb < 0 || vb > 100)) {
return 'Verkäufer-Prozentwert muss zwischen 0 und 100 sein';
}
}
return null;
}
// --- Tariff-specific rules (provisionsregeln) ---
router.post('/provisionsregeln', asyncHandler(async (req, res) => {
const { tarif_id, provision_typ, betrag, basis_euro, beschreibung, gueltig_ab,
verkaeufer_provision_typ, verkaeufer_provision_betrag, verkaeufer_provision_beschreibung } = req.body || {};
if (!tarif_id) return res.status(400).json({ error: 'Tarif ist erforderlich' });
const err = validateRule({ provision_typ, betrag, basis_euro, verkaeufer_provision_typ, verkaeufer_provision_betrag });
if (err) return res.status(400).json({ error: err });
const result = await db.run(
`INSERT INTO provisionsregeln
(tarif_id, provision_typ, betrag, basis_euro, beschreibung, gueltig_ab,
verkaeufer_provision_typ, verkaeufer_provision_betrag, verkaeufer_provision_beschreibung)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
tarif_id, provision_typ, betrag,
provision_typ === 'prozent' ? parseFloat(basis_euro) : null,
beschreibung || null,
gueltig_ab || new Date().toISOString().split('T')[0],
verkaeufer_provision_typ || null,
verkaeufer_provision_betrag != null ? parseFloat(verkaeufer_provision_betrag) : null,
verkaeufer_provision_beschreibung || null,
]
);
res.json({ id: result.lastID, message: 'Provisionsregel erfolgreich erstellt' });
}));
router.put('/provisionsregeln/:id', asyncHandler(async (req, res) => {
const { id } = req.params;
const current = await db.get('SELECT * FROM provisionsregeln WHERE id = ?', [id]);
if (!current) return res.status(404).json({ error: 'Provisionsregel nicht gefunden' });
const merged = {
provision_typ: req.body.provision_typ !== undefined ? req.body.provision_typ : current.provision_typ,
betrag: req.body.betrag !== undefined ? req.body.betrag : current.betrag,
basis_euro: req.body.basis_euro !== undefined ? req.body.basis_euro : current.basis_euro,
beschreibung: req.body.beschreibung !== undefined ? req.body.beschreibung : current.beschreibung,
gueltig_ab: req.body.gueltig_ab !== undefined ? req.body.gueltig_ab : current.gueltig_ab,
aktiv: req.body.aktiv !== undefined ? req.body.aktiv : current.aktiv,
verkaeufer_provision_typ: req.body.verkaeufer_provision_typ !== undefined ? req.body.verkaeufer_provision_typ : current.verkaeufer_provision_typ,
verkaeufer_provision_betrag: req.body.verkaeufer_provision_betrag !== undefined ? req.body.verkaeufer_provision_betrag : current.verkaeufer_provision_betrag,
verkaeufer_provision_beschreibung: req.body.verkaeufer_provision_beschreibung !== undefined ? req.body.verkaeufer_provision_beschreibung : current.verkaeufer_provision_beschreibung,
};
const verr = validateRule(merged);
if (verr) return res.status(400).json({ error: verr });
await db.run(
`UPDATE provisionsregeln
SET provision_typ = ?, betrag = ?, basis_euro = ?, beschreibung = ?, gueltig_ab = ?, aktiv = ?,
verkaeufer_provision_typ = ?, verkaeufer_provision_betrag = ?, verkaeufer_provision_beschreibung = ?
WHERE id = ?`,
[
merged.provision_typ, merged.betrag,
merged.provision_typ === 'prozent' ? merged.basis_euro : null,
merged.beschreibung, merged.gueltig_ab, merged.aktiv,
merged.verkaeufer_provision_typ,
merged.verkaeufer_provision_betrag,
merged.verkaeufer_provision_beschreibung,
id,
]
);
res.json({ message: 'Provisionsregel erfolgreich aktualisiert' });
}));
// --- Global provider rules (anbieter_provisionsregeln) ---
router.post('/anbieter-provisionsregeln', asyncHandler(async (req, res) => {
const { anbieter_id, provision_typ, betrag, basis_euro, beschreibung, gueltig_ab,
verkaeufer_provision_typ, verkaeufer_provision_betrag, verkaeufer_provision_beschreibung } = req.body || {};
if (!anbieter_id) return res.status(400).json({ error: 'Anbieter ist erforderlich' });
const err = validateRule({ provision_typ, betrag, basis_euro, verkaeufer_provision_typ, verkaeufer_provision_betrag });
if (err) return res.status(400).json({ error: err });
const result = await db.run(
`INSERT INTO anbieter_provisionsregeln
(anbieter_id, provision_typ, betrag, basis_euro, beschreibung, gueltig_ab,
verkaeufer_provision_typ, verkaeufer_provision_betrag, verkaeufer_provision_beschreibung)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
anbieter_id, provision_typ, betrag,
provision_typ === 'prozent' ? parseFloat(basis_euro) : null,
beschreibung || null,
gueltig_ab || new Date().toISOString().split('T')[0],
verkaeufer_provision_typ || null,
verkaeufer_provision_betrag != null ? parseFloat(verkaeufer_provision_betrag) : null,
verkaeufer_provision_beschreibung || null,
]
);
res.json({ id: result.lastID, message: 'Globale Provisionsregel erfolgreich erstellt' });
}));
router.put('/anbieter-provisionsregeln/:id', asyncHandler(async (req, res) => {
const { id } = req.params;
const current = await db.get('SELECT * FROM anbieter_provisionsregeln WHERE id = ?', [id]);
if (!current) return res.status(404).json({ error: 'Globale Provisionsregel nicht gefunden' });
const merged = {
provision_typ: req.body.provision_typ !== undefined ? req.body.provision_typ : current.provision_typ,
betrag: req.body.betrag !== undefined ? req.body.betrag : current.betrag,
basis_euro: req.body.basis_euro !== undefined ? req.body.basis_euro : current.basis_euro,
beschreibung: req.body.beschreibung !== undefined ? req.body.beschreibung : current.beschreibung,
gueltig_ab: req.body.gueltig_ab !== undefined ? req.body.gueltig_ab : current.gueltig_ab,
aktiv: req.body.aktiv !== undefined ? req.body.aktiv : current.aktiv,
verkaeufer_provision_typ: req.body.verkaeufer_provision_typ !== undefined ? req.body.verkaeufer_provision_typ : current.verkaeufer_provision_typ,
verkaeufer_provision_betrag: req.body.verkaeufer_provision_betrag !== undefined ? req.body.verkaeufer_provision_betrag : current.verkaeufer_provision_betrag,
verkaeufer_provision_beschreibung: req.body.verkaeufer_provision_beschreibung !== undefined ? req.body.verkaeufer_provision_beschreibung : current.verkaeufer_provision_beschreibung,
};
const verr = validateRule(merged);
if (verr) return res.status(400).json({ error: verr });
await db.run(
`UPDATE anbieter_provisionsregeln
SET provision_typ = ?, betrag = ?, basis_euro = ?, beschreibung = ?, gueltig_ab = ?, aktiv = ?,
verkaeufer_provision_typ = ?, verkaeufer_provision_betrag = ?, verkaeufer_provision_beschreibung = ?
WHERE id = ?`,
[
merged.provision_typ, merged.betrag,
merged.provision_typ === 'prozent' ? merged.basis_euro : null,
merged.beschreibung, merged.gueltig_ab, merged.aktiv,
merged.verkaeufer_provision_typ,
merged.verkaeufer_provision_betrag,
merged.verkaeufer_provision_beschreibung,
id,
]
);
res.json({ message: 'Globale Provisionsregel erfolgreich aktualisiert' });
}));
module.exports = router;
+217
View File
@@ -0,0 +1,217 @@
const express = require('express');
const db = require('../db');
const asyncHandler = require('../middleware/asyncHandler');
const router = express.Router();
function validateDate(s) {
return typeof s === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(s) && !isNaN(new Date(s).getTime());
}
function validateHardware(h) {
return !h || String(h).length === 15;
}
function validateAmount(a) {
return a === null || a === undefined || (Number.isFinite(parseFloat(a)) && parseFloat(a) >= 0);
}
function validateSchaltungBody(body) {
const { schaltungs_id, eingabe_datum, schaltungs_datum, vorname, nachname,
rufnummer, anbieter_id, tarif_id, hardware, hardware_ek, kundenverkaufspreis_netto } = body || {};
if (!schaltungs_id || !schaltungs_id.trim()) return 'Schaltungs-ID ist erforderlich';
if (!validateDate(eingabe_datum)) return 'Ungültiges Eingabe-Datum (Format: YYYY-MM-DD)';
if (!validateDate(schaltungs_datum)) return 'Ungültiges Schaltungs-Datum (Format: YYYY-MM-DD)';
if (!vorname || !vorname.trim()) return 'Vorname ist erforderlich';
if (!nachname || !nachname.trim()) return 'Nachname ist erforderlich';
if (!rufnummer || !rufnummer.trim()) return 'Rufnummer ist erforderlich';
if (!anbieter_id) return 'Anbieter ist erforderlich';
if (!tarif_id) return 'Tarif ist erforderlich';
if (!validateHardware(hardware)) return 'Hardware muss genau 15 Zeichen lang sein';
if (hardware && hardware.trim() !== '') {
if (!validateAmount(hardware_ek)) return 'Hardware-EK muss eine positive Zahl sein';
if (!validateAmount(kundenverkaufspreis_netto)) return 'Kundenverkaufspreis muss eine positive Zahl sein';
}
return null;
}
router.get('/', asyncHandler(async (req, res) => {
const rows = await db.all(
`SELECT s.*, a.name AS anbieter_name, t.name AS tarif_name
FROM schaltungen s
JOIN anbieter a ON s.anbieter_id = a.id
JOIN tarife t ON s.tarif_id = t.id
ORDER BY s.created_at DESC`
);
res.json(rows);
}));
router.post('/', asyncHandler(async (req, res) => {
const err = validateSchaltungBody(req.body);
if (err) return res.status(400).json({ error: err });
const b = req.body;
try {
const result = await db.run(
`INSERT INTO schaltungen
(schaltungs_id, eingabe_datum, schaltungs_datum, vorname, nachname, firma, rufnummer,
anbieter_id, tarif_id, hardware, hardware_ek, kundenverkaufspreis_netto)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
b.schaltungs_id, b.eingabe_datum, b.schaltungs_datum, b.vorname, b.nachname,
b.firma || null, b.rufnummer, b.anbieter_id, b.tarif_id, b.hardware || null,
b.hardware_ek || null, b.kundenverkaufspreis_netto || null,
]
);
res.json({ id: result.lastID, message: 'Schaltung erfolgreich erstellt' });
} catch (e) {
if (/UNIQUE/.test(e.message)) {
return res.status(400).json({ error: 'Schaltungs-ID existiert bereits' });
}
throw e;
}
}));
router.put('/:id', asyncHandler(async (req, res) => {
const { id } = req.params;
const err = validateSchaltungBody(req.body);
if (err) return res.status(400).json({ error: err });
const b = req.body;
const tarifoptionen = Array.isArray(b.tarifoptionen) ? b.tarifoptionen : [];
await db.transaction(async () => {
const upd = await db.run(
`UPDATE schaltungen
SET schaltungs_id = ?, eingabe_datum = ?, schaltungs_datum = ?, vorname = ?, nachname = ?,
firma = ?, rufnummer = ?, anbieter_id = ?, tarif_id = ?, hardware = ?,
hardware_ek = ?, kundenverkaufspreis_netto = ?
WHERE id = ?`,
[
b.schaltungs_id, b.eingabe_datum, b.schaltungs_datum, b.vorname, b.nachname,
b.firma || null, b.rufnummer, b.anbieter_id, b.tarif_id, b.hardware || null,
b.hardware_ek || null, b.kundenverkaufspreis_netto || null, id,
]
);
if (upd.changes === 0) {
const e = new Error('Schaltung nicht gefunden');
e.status = 404;
throw e;
}
await db.run('DELETE FROM schaltungen_tarifoptionen WHERE schaltung_id = ?', [id]);
for (const tarifoptionId of tarifoptionen) {
await db.run(
'INSERT INTO schaltungen_tarifoptionen (schaltung_id, tarifoption_id) VALUES (?, ?)',
[id, tarifoptionId]
);
}
});
res.json({ message: 'Schaltung erfolgreich aktualisiert' });
}));
router.delete('/:id', asyncHandler(async (req, res) => {
const { id } = req.params;
const { loeschgrund } = req.body || {};
if (!loeschgrund || !loeschgrund.trim()) {
return res.status(400).json({ error: 'Löschgrund ist erforderlich' });
}
const schaltung = await db.get(
`SELECT s.*, a.name AS anbieter_name, t.name AS tarif_name
FROM schaltungen s
JOIN anbieter a ON s.anbieter_id = a.id
JOIN tarife t ON s.tarif_id = t.id
WHERE s.id = ?`,
[id]
);
if (!schaltung) return res.status(404).json({ error: 'Schaltung nicht gefunden' });
const documents = await db.all('SELECT filepath FROM dokumente WHERE schaltung_id = ?', [id]);
const { safeUploadPath } = req.app.locals;
const fs = require('fs');
await db.transaction(async () => {
await db.run(
`INSERT INTO geloeschte_schaltungen (
original_id, schaltungs_id, eingabe_datum, schaltungs_datum, vorname, nachname,
firma, rufnummer, anbieter_id, tarif_id, hardware, anbieter_name, tarif_name, loeschgrund, geloescht_von
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
schaltung.id, schaltung.schaltungs_id, schaltung.eingabe_datum, schaltung.schaltungs_datum,
schaltung.vorname, schaltung.nachname, schaltung.firma, schaltung.rufnummer,
schaltung.anbieter_id, schaltung.tarif_id, schaltung.hardware,
schaltung.anbieter_name, schaltung.tarif_name, loeschgrund.trim(),
req.session && req.session.username ? req.session.username : 'System',
]
);
// Existing DBs may lack ON DELETE CASCADE on dokumente — delete explicitly.
await db.run('DELETE FROM dokumente WHERE schaltung_id = ?', [id]);
await db.run('DELETE FROM schaltungen WHERE id = ?', [id]);
});
// Best-effort file cleanup, outside the transaction.
let deletedFiles = 0;
for (const doc of documents) {
const filePath = safeUploadPath(doc.filepath);
if (!filePath) {
console.error(`Skipping file delete - invalid path: ${doc.filepath}`);
continue;
}
try {
fs.unlinkSync(filePath);
deletedFiles++;
} catch (e) {
console.error(`Error deleting ${filePath}:`, e.message);
}
}
res.json({
message: 'Schaltung erfolgreich gelöscht und archiviert',
loeschgrund: loeschgrund.trim(),
deleted_files: deletedFiles,
});
}));
// Tarifoptionen linked to a specific schaltung
router.get('/:id/tarifoptionen', asyncHandler(async (req, res) => {
const { id } = req.params;
const rows = await db.all(
`SELECT t.*, st.id AS schaltung_tarifoption_id
FROM tarifoptionen t
JOIN schaltungen_tarifoptionen st ON t.id = st.tarifoption_id
WHERE st.schaltung_id = ?
ORDER BY t.name`,
[id]
);
res.json(rows);
}));
router.post('/:id/tarifoptionen', asyncHandler(async (req, res) => {
const schaltung_id = req.params.id;
const { tarifoption_id } = req.body || {};
if (!tarifoption_id) return res.status(400).json({ error: 'tarifoption_id ist erforderlich' });
try {
const result = await db.run(
'INSERT INTO schaltungen_tarifoptionen (schaltung_id, tarifoption_id) VALUES (?, ?)',
[schaltung_id, tarifoption_id]
);
res.json({ id: result.lastID, message: 'Tarifoption erfolgreich hinzugefügt' });
} catch (err) {
if (/UNIQUE/.test(err.message)) {
return res.status(400).json({ error: 'Tarifoption bereits hinzugefügt' });
}
throw err;
}
}));
router.delete('/:schaltung_id/tarifoptionen/:tarifoption_id', asyncHandler(async (req, res) => {
const { schaltung_id, tarifoption_id } = req.params;
const result = await db.run(
'DELETE FROM schaltungen_tarifoptionen WHERE schaltung_id = ? AND tarifoption_id = ?',
[schaltung_id, tarifoption_id]
);
if (result.changes === 0) return res.status(404).json({ error: 'Verknüpfung nicht gefunden' });
res.json({ message: 'Tarifoption erfolgreich entfernt' });
}));
module.exports = router;
+123
View File
@@ -0,0 +1,123 @@
const express = require('express');
const db = require('../db');
const asyncHandler = require('../middleware/asyncHandler');
const router = express.Router();
function validateAmount(amount) {
return amount === null || amount === undefined || (Number.isFinite(parseFloat(amount)) && parseFloat(amount) >= 0);
}
router.get('/', asyncHandler(async (req, res) => {
const rows = await db.all(
`SELECT t.*, a.name AS anbieter_name
FROM tarife t
JOIN anbieter a ON t.anbieter_id = a.id
ORDER BY a.name, t.name`
);
res.json(rows);
}));
router.get('/:anbieter_id', asyncHandler(async (req, res) => {
const { anbieter_id } = req.params;
const rows = await db.all('SELECT * FROM tarife WHERE anbieter_id = ? ORDER BY name', [anbieter_id]);
res.json(rows);
}));
router.post('/', asyncHandler(async (req, res) => {
const { name, anbieter_id, provision_euro, beschreibung } = req.body || {};
if (!name || !name.trim()) {
return res.status(400).json({ error: 'Tarifname ist erforderlich' });
}
if (!anbieter_id) {
return res.status(400).json({ error: 'Anbieter ist erforderlich' });
}
if (!validateAmount(provision_euro)) {
return res.status(400).json({ error: 'Provision muss eine positive Zahl sein' });
}
try {
const result = await db.run(
`INSERT INTO tarife (name, anbieter_id, provision_euro, beschreibung) VALUES (?, ?, ?, ?)`,
[name.trim(), anbieter_id, provision_euro || 0, beschreibung || null]
);
res.json({
id: result.lastID,
name: name.trim(),
anbieter_id,
provision_euro: provision_euro || 0,
beschreibung: beschreibung || null,
});
} catch (err) {
if (/UNIQUE/.test(err.message)) {
return res.status(400).json({ error: 'Tarif mit diesem Namen existiert bereits für diesen Anbieter' });
}
throw err;
}
}));
router.put('/:id', asyncHandler(async (req, res) => {
const { id } = req.params;
const { name, provision_euro, beschreibung } = req.body || {};
if (!name) return res.status(400).json({ error: 'Tarif-Name ist erforderlich' });
if (!validateAmount(provision_euro)) {
return res.status(400).json({ error: 'Provision muss eine positive Zahl sein' });
}
const result = await db.run(
`UPDATE tarife SET name = ?, provision_euro = ?, beschreibung = ? WHERE id = ?`,
[name, provision_euro || 0, beschreibung || null, id]
);
if (result.changes === 0) return res.status(404).json({ error: 'Tarif nicht gefunden' });
res.json({ message: 'Tarif erfolgreich aktualisiert' });
}));
router.delete('/:id', asyncHandler(async (req, res) => {
const { id } = req.params;
const used = await db.get(
'SELECT EXISTS(SELECT 1 FROM schaltungen WHERE tarif_id = ?) AS has_schaltungen',
[id]
);
if (used.has_schaltungen) {
return res.status(400).json({ error: 'Tarif kann nicht gelöscht werden: Tarif wird in Schaltungen verwendet' });
}
const result = await db.run('DELETE FROM tarife WHERE id = ?', [id]);
if (result.changes === 0) return res.status(404).json({ error: 'Tarif nicht gefunden' });
res.json({ message: 'Tarif erfolgreich gelöscht' });
}));
router.get('/:tarif_id/tarifoptionen', asyncHandler(async (req, res) => {
const { tarif_id } = req.params;
const rows = await db.all(
'SELECT * FROM tarifoptionen WHERE tarif_id = ? AND aktiv = 1 ORDER BY name',
[tarif_id]
);
res.json(rows);
}));
router.get('/:id/provisionsregeln', asyncHandler(async (req, res) => {
const { id } = req.params;
const rows = await db.all(
`SELECT pr.*, t.name AS tarif_name, a.name AS anbieter_name
FROM provisionsregeln pr
JOIN tarife t ON pr.tarif_id = t.id
JOIN anbieter a ON t.anbieter_id = a.id
WHERE pr.tarif_id = ? AND pr.aktiv = 1
ORDER BY pr.gueltig_ab DESC`,
[id]
);
res.json(rows);
}));
router.put('/:id/mitarbeiter-provision', asyncHandler(async (req, res) => {
const { id } = req.params;
const { mitarbeiter_provision } = req.body || {};
const result = await db.run(
'UPDATE tarife SET mitarbeiter_provision = ? WHERE id = ?',
[mitarbeiter_provision || 0, id]
);
if (result.changes === 0) return res.status(404).json({ error: 'Tarif nicht gefunden' });
res.json({ message: 'Mitarbeiter-Provision erfolgreich aktualisiert' });
}));
module.exports = router;
+205
View File
@@ -0,0 +1,205 @@
const express = require('express');
const db = require('../db');
const asyncHandler = require('../middleware/asyncHandler');
const router = express.Router();
// Helper: find or insert a tarifoptionen row matching the new structure's option.
// Used by the /multi endpoints to keep the old tarifoptionen table in sync.
async function syncLegacyOption(anbieter_id, tarif_id, name, code, provision_euro, beschreibung) {
try {
await db.run(
`INSERT OR IGNORE INTO tarifoptionen (anbieter_id, tarif_id, name, code, provision_euro, beschreibung)
VALUES (?, ?, ?, ?, ?, ?)`,
[anbieter_id, tarif_id, name, code || null, provision_euro || 0, beschreibung || null]
);
} catch (err) {
console.error('Legacy tarifoption sync failed:', err.message);
}
}
// --- Legacy single-tariff API (per-(anbieter,tarif) options) ---
router.get('/', asyncHandler(async (req, res) => {
const rows = await db.all(
`SELECT t.*, a.name AS anbieter_name, ta.name AS tarif_name
FROM tarifoptionen t
JOIN anbieter a ON t.anbieter_id = a.id
LEFT JOIN tarife ta ON t.tarif_id = ta.id
WHERE t.aktiv = 1
ORDER BY a.name, t.tarif_id IS NULL DESC, COALESCE(ta.name, ''), t.name`
);
res.json(rows);
}));
router.post('/', asyncHandler(async (req, res) => {
const { anbieter_id, tarif_id, name, code, provision_euro, beschreibung } = req.body || {};
if (!anbieter_id && !tarif_id) {
return res.status(400).json({ error: 'Entweder Anbieter oder Tarif muss angegeben werden' });
}
if (!name || !name.trim()) {
return res.status(400).json({ error: 'Name ist erforderlich' });
}
let resolvedAnbieterId = anbieter_id;
if (tarif_id && !resolvedAnbieterId) {
const tarif = await db.get('SELECT anbieter_id FROM tarife WHERE id = ?', [tarif_id]);
if (!tarif) return res.status(404).json({ error: 'Tarif nicht gefunden' });
resolvedAnbieterId = tarif.anbieter_id;
}
try {
const result = await db.run(
`INSERT INTO tarifoptionen (anbieter_id, tarif_id, name, code, provision_euro, beschreibung)
VALUES (?, ?, ?, ?, ?, ?)`,
[resolvedAnbieterId, tarif_id || null, name.trim(), code || null, provision_euro || 0, beschreibung || null]
);
res.json({ id: result.lastID, message: 'Tarifoption erfolgreich erstellt' });
} catch (err) {
if (/UNIQUE/.test(err.message)) {
const scope = tarif_id ? 'für diesen Tarif' : 'für diesen Anbieter';
return res.status(400).json({ error: `Eine Tarifoption mit diesem Namen existiert bereits ${scope}` });
}
throw err;
}
}));
router.put('/:id', asyncHandler(async (req, res) => {
const { id } = req.params;
const { name, provision_euro } = req.body || {};
if (!name) return res.status(400).json({ error: 'Tarifoption-Name ist erforderlich' });
const result = await db.run(
'UPDATE tarifoptionen SET name = ?, provision_euro = ? WHERE id = ?',
[name, provision_euro || 0, id]
);
if (result.changes === 0) return res.status(404).json({ error: 'Tarifoption nicht gefunden' });
res.json({ message: 'Tarifoption erfolgreich aktualisiert' });
}));
router.delete('/:id', asyncHandler(async (req, res) => {
const { id } = req.params;
const result = await db.run('DELETE FROM tarifoptionen WHERE id = ?', [id]);
if (result.changes === 0) return res.status(404).json({ error: 'Tarifoption nicht gefunden' });
res.json({ message: 'Tarifoption erfolgreich gelöscht' });
}));
// --- Multi-tariff API (tarifoptionen_new + tarifoptionen_tarife) ---
router.get('/multi', asyncHandler(async (req, res) => {
const rows = await db.all(
`SELECT tn.id, tn.name, tn.code, tn.provision_euro, tn.beschreibung, tn.aktiv, tn.created_at,
GROUP_CONCAT(t.name || ' (' || a.name || ')') AS tarif_names,
GROUP_CONCAT(t.id) AS tarif_ids
FROM tarifoptionen_new tn
LEFT JOIN tarifoptionen_tarife tt ON tn.id = tt.tarifoption_id
LEFT JOIN tarife t ON tt.tarif_id = t.id
LEFT JOIN anbieter a ON t.anbieter_id = a.id
WHERE tn.aktiv = 1
GROUP BY tn.id, tn.name, tn.code, tn.provision_euro, tn.beschreibung, tn.aktiv, tn.created_at
ORDER BY tn.created_at DESC`
);
res.json(rows);
}));
router.post('/multi', asyncHandler(async (req, res) => {
const { anbieter_id, tarif_ids = [], is_anbieter_weit = false, name, code, provision_euro, beschreibung } = req.body || {};
if (!anbieter_id || !name) return res.status(400).json({ error: 'Anbieter und Name sind erforderlich' });
if (!is_anbieter_weit && (!tarif_ids || tarif_ids.length === 0)) {
return res.status(400).json({ error: 'Bei tarif-spezifischen Optionen müssen Tarife ausgewählt werden' });
}
const newId = await db.transaction(async () => {
const inserted = await db.run(
'INSERT INTO tarifoptionen_new (name, code, provision_euro, beschreibung) VALUES (?, ?, ?, ?)',
[name, code || null, provision_euro || 0, beschreibung || null]
);
const tarifoptionId = inserted.lastID;
let targetTarifIds = tarif_ids;
if (is_anbieter_weit) {
const tarifs = await db.all('SELECT id FROM tarife WHERE anbieter_id = ?', [anbieter_id]);
targetTarifIds = tarifs.map(t => t.id);
}
for (const tarifId of targetTarifIds) {
await db.run(
'INSERT INTO tarifoptionen_tarife (tarifoption_id, tarif_id) VALUES (?, ?)',
[tarifoptionId, tarifId]
);
await syncLegacyOption(anbieter_id, tarifId, name, code, provision_euro, beschreibung);
}
return { id: tarifoptionId, count: targetTarifIds.length };
});
res.json({ id: newId.id, message: 'Tarifoption erfolgreich erstellt', assignments: newId.count });
}));
router.put('/multi/:id', asyncHandler(async (req, res) => {
const { id } = req.params;
const { name, code, provision_euro, beschreibung, anbieter_id, is_anbieter_weit, tarif_ids } = req.body || {};
if (!name) return res.status(400).json({ error: 'Tarifoption-Name ist erforderlich' });
await db.transaction(async () => {
const upd = await db.run(
'UPDATE tarifoptionen_new SET name = ?, code = ?, provision_euro = ?, beschreibung = ? WHERE id = ?',
[name, code || null, provision_euro || 0, beschreibung || null, id]
);
if (upd.changes === 0) {
const e = new Error('Tarifoption nicht gefunden');
e.status = 404;
throw e;
}
await db.run('DELETE FROM tarifoptionen_tarife WHERE tarifoption_id = ?', [id]);
// Best-effort cleanup of legacy rows that may have been synced under this name/code.
await db.run(
'DELETE FROM tarifoptionen WHERE name = ? AND (code = ? OR (code IS NULL AND ? IS NULL))',
[name, code || null, code || null]
);
let targetTarifIds = tarif_ids || [];
if (is_anbieter_weit && anbieter_id) {
const tarifs = await db.all('SELECT id FROM tarife WHERE anbieter_id = ?', [anbieter_id]);
targetTarifIds = tarifs.map(t => t.id);
}
for (const tarifId of targetTarifIds) {
await db.run(
'INSERT INTO tarifoptionen_tarife (tarifoption_id, tarif_id) VALUES (?, ?)',
[id, tarifId]
);
const tarif = await db.get('SELECT anbieter_id FROM tarife WHERE id = ?', [tarifId]);
if (tarif) {
await syncLegacyOption(tarif.anbieter_id, tarifId, name, code, provision_euro, beschreibung);
}
}
});
res.json({ message: 'Tarifoption erfolgreich aktualisiert' });
}));
router.delete('/multi/:id', asyncHandler(async (req, res) => {
const { id } = req.params;
const option = await db.get('SELECT name, code FROM tarifoptionen_new WHERE id = ?', [id]);
if (!option) return res.status(404).json({ error: 'Tarifoption nicht gefunden' });
await db.transaction(async () => {
await db.run('DELETE FROM tarifoptionen_tarife WHERE tarifoption_id = ?', [id]);
await db.run(
'DELETE FROM tarifoptionen WHERE name = ? AND (code = ? OR (code IS NULL AND ? IS NULL))',
[option.name, option.code, option.code]
);
await db.run('DELETE FROM tarifoptionen_new WHERE id = ?', [id]);
});
res.json({ message: 'Tarifoption erfolgreich gelöscht' });
}));
// Tariff-specific tarifoption list (used by frontend when adding options to a schaltung)
router.get('/tarif/:tarif_id', asyncHandler(async (req, res) => {
const { tarif_id } = req.params;
const rows = await db.all(
'SELECT * FROM tarifoptionen WHERE tarif_id = ? AND aktiv = 1 ORDER BY name',
[tarif_id]
);
res.json(rows);
}));
module.exports = router;
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env node
// CLI: create-user <username> <password> [--admin]
// Useful for adding additional users outside the web UI.
const bcrypt = require('bcryptjs');
const { initDatabase, getDatabase, closeDatabase } = require('../database');
async function main() {
const args = process.argv.slice(2);
if (args.length < 2) {
console.error('Usage: npm run create-user -- <username> <password> [--admin]');
process.exit(1);
}
const username = args[0];
const password = args[1];
const isAdmin = args.includes('--admin');
if (password.length < 8) {
console.error('Passwort muss mindestens 8 Zeichen lang sein');
process.exit(1);
}
initDatabase();
// Give initDatabase a moment to run its serialized statements.
await new Promise(r => setTimeout(r, 200));
const db = getDatabase();
const hash = await bcrypt.hash(password, 10);
await new Promise((resolve, reject) => {
db.run(
'INSERT INTO users (username, password_hash, display_name, is_admin) VALUES (?, ?, ?, ?)',
[username, hash, username, isAdmin ? 1 : 0],
function (err) {
if (err) reject(err);
else resolve(this.lastID);
}
);
}).then((id) => {
console.log(`User '${username}' angelegt (id=${id}, admin=${isAdmin}).`);
}).catch((err) => {
if (/UNIQUE/.test(err.message)) {
console.error(`User '${username}' existiert bereits.`);
} else {
console.error('Fehler:', err.message);
}
process.exitCode = 1;
});
closeDatabase();
}
main().catch(err => {
console.error(err);
process.exit(1);
});
+115 -2085
View File
File diff suppressed because it is too large. Load diff
+138
View File
@@ -0,0 +1,138 @@
const test = require('node:test');
const assert = require('node:assert');
const { calculate } = require('../provisionService');
function baseInput(overrides = {}) {
return Object.assign({
schaltung: { hardware: null, hardware_ek: null, kundenverkaufspreis_netto: null },
tarif: { id: 1, name: 'Tarif X', provision_euro: 100, mitarbeiter_provision: 0, beschreibung: null },
tarifRule: null,
anbieterRule: null,
tarifoptionen: [],
mitarbeiterProzentsatz: 10,
}, overrides);
}
test('falls back to direct tariff commission when no rules exist', () => {
const r = calculate(baseInput());
assert.strictEqual(r.shop.betrag, 100);
assert.strictEqual(r.shop.quelle, 'Direkte Tarif-Provision');
});
test('tariff-specific fix rule wins over global and direct', () => {
const r = calculate(baseInput({
tarifRule: { provision_typ: 'fix', betrag: 50, basis_euro: null },
anbieterRule: { provision_typ: 'fix', betrag: 30, basis_euro: null },
}));
assert.strictEqual(r.shop.betrag, 50);
assert.strictEqual(r.shop.quelle, 'Tarif-spezifische Regel');
});
test('global anbieter rule applied when no tariff-specific rule', () => {
const r = calculate(baseInput({
tarifRule: null,
anbieterRule: { provision_typ: 'fix', betrag: 25, basis_euro: null },
}));
assert.strictEqual(r.shop.betrag, 25);
assert.strictEqual(r.shop.quelle, 'Globale Anbieter-Regel');
});
test('percent rule uses basis_euro as the base', () => {
const r = calculate(baseInput({
anbieterRule: { provision_typ: 'prozent', betrag: 12, basis_euro: 200 },
}));
assert.strictEqual(r.shop.betrag, 24);
});
test('percent rule with missing basis_euro yields 0', () => {
const r = calculate(baseInput({
anbieterRule: { provision_typ: 'prozent', betrag: 12, basis_euro: null },
}));
assert.strictEqual(r.shop.betrag, 0);
});
test('tarifoptionen are added (positive)', () => {
const r = calculate(baseInput({
tarifoptionen: [{ name: 'Bonus', provision_euro: 25 }],
}));
assert.strictEqual(r.shop.betrag, 125);
assert.strictEqual(r.tarifoptionen.betrag, 25);
});
test('tarifoptionen rebates (negative) reduce commission', () => {
const r = calculate(baseInput({
tarifoptionen: [{ name: 'Rabatt', provision_euro: -30 }],
}));
assert.strictEqual(r.shop.betrag, 70);
});
test('hardware adds (KVP - EK) to commission', () => {
const r = calculate(baseInput({
schaltung: { hardware: '123456789012345', hardware_ek: 40, kundenverkaufspreis_netto: 100 },
}));
assert.strictEqual(r.shop.betrag, 160); // 100 + (100 - 40)
assert.strictEqual(r.hardware_gewinn, 60);
});
test('hardware ignored when hardware string is empty', () => {
const r = calculate(baseInput({
schaltung: { hardware: '', hardware_ek: 40, kundenverkaufspreis_netto: 100 },
}));
assert.strictEqual(r.shop.betrag, 100);
});
test('employee fix rule wins regardless of percent fallback', () => {
const r = calculate(baseInput({
tarifRule: {
provision_typ: 'fix', betrag: 100, basis_euro: null,
verkaeufer_provision_typ: 'fix', verkaeufer_provision_betrag: 15,
},
}));
assert.strictEqual(r.shop.betrag, 100);
assert.strictEqual(r.mitarbeiter.betrag, 15);
});
test('employee percent rule uses shop commission as base', () => {
const r = calculate(baseInput({
tarifRule: {
provision_typ: 'fix', betrag: 200, basis_euro: null,
verkaeufer_provision_typ: 'prozent', verkaeufer_provision_betrag: 10,
},
}));
assert.strictEqual(r.shop.betrag, 200);
assert.strictEqual(r.mitarbeiter.betrag, 20);
});
test('employee falls back to anbieter percentage when rule has no employee fields', () => {
const r = calculate(baseInput({
tarif: { provision_euro: 100 },
mitarbeiterProzentsatz: 8,
}));
assert.strictEqual(r.shop.betrag, 100);
assert.strictEqual(r.mitarbeiter.betrag, 8); // 8% of 100
});
test('rounding produces 2-decimal numbers', () => {
const r = calculate(baseInput({
anbieterRule: { provision_typ: 'prozent', betrag: 33.33, basis_euro: 100 },
}));
assert.strictEqual(r.shop.betrag, 33.33);
});
test('full scenario: tariff rule + tarifoptionen + hardware + employee %', () => {
const r = calculate({
schaltung: { hardware: '111111111111111', hardware_ek: 50, kundenverkaufspreis_netto: 80 },
tarif: { provision_euro: 0 },
tarifRule: {
provision_typ: 'fix', betrag: 200, basis_euro: null,
verkaeufer_provision_typ: 'prozent', verkaeufer_provision_betrag: 10,
},
anbieterRule: null,
tarifoptionen: [{ name: 'Aktion', provision_euro: 25 }, { name: 'Rabatt', provision_euro: -45 }],
mitarbeiterProzentsatz: 0,
});
// shop = 200 + (25 - 45) + (80 - 50) = 200 - 20 + 30 = 210
assert.strictEqual(r.shop.betrag, 210);
// employee = 10% of 210 = 21
assert.strictEqual(r.mitarbeiter.betrag, 21);
});