Initial commit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 18:15:11 +02:00
commit c65c9f1751
19 changed files with 3633 additions and 0 deletions
+234
View File
@@ -0,0 +1,234 @@
'use strict';
const express = require('express');
const path = require('path');
const fs = require('fs');
const methodOverride = require('method-override');
const Database = require('better-sqlite3');
const app = express();
const PORT = process.env.PORT || 3000;
// ── Database bootstrap ────────────────────────────────────────────────────────
const dataDir = path.join(__dirname, 'data');
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
const db = new Database(path.join(dataDir, 'bewerbungen.db'));
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS bewerbungen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
datum DATE NOT NULL,
firma TEXT NOT NULL,
stelle TEXT NOT NULL,
art TEXT,
status TEXT,
notizen TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
name TEXT,
adresse TEXT,
kundennummer TEXT
);
INSERT OR IGNORE INTO settings (id) VALUES (1);
`);
// ── Constants ─────────────────────────────────────────────────────────────────
const ART_OPTIONEN = [
'E-Mail', 'Online-Portal', 'Indeed', 'StepStone', 'Firmenwebsite',
'Post', 'Initiativbewerbung', 'Arbeitsagentur', 'Sonstiges'
];
const STATUS_OPTIONEN = [
'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
'Absage', 'Einstellung', 'Keine Rückmeldung'
];
const MONATE = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'
];
// ── Helpers ───────────────────────────────────────────────────────────────────
function sanitize(val, maxLen = 2000) {
if (val === null || val === undefined) return '';
return String(val).trim().slice(0, maxLen);
}
function safeJson(obj) {
return JSON.stringify(obj)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026');
}
function getSettings() {
return db.prepare('SELECT * FROM settings WHERE id = 1').get() || {};
}
// ── Middleware ────────────────────────────────────────────────────────────────
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(methodOverride('_method'));
app.use(express.static(path.join(__dirname, 'public')));
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
// ── Routes ────────────────────────────────────────────────────────────────────
// GET / Übersicht
app.get('/', (req, res) => {
const now = new Date();
const monat = Math.min(12, Math.max(1, parseInt(req.query.monat) || (now.getMonth() + 1)));
const jahr = parseInt(req.query.jahr) || now.getFullYear();
const monatStr = String(monat).padStart(2, '0');
const vonDatum = `${jahr}-${monatStr}-01`;
const bisDatum = `${jahr}-${monatStr}-31`;
const bewerbungen = db.prepare(`
SELECT * FROM bewerbungen
WHERE datum BETWEEN ? AND ?
ORDER BY datum DESC, id DESC
`).all(vonDatum, bisDatum);
// Statistics
const stats = { gesamt: bewerbungen.length, positiv: 0, absagen: 0, ausstehend: 0 };
for (const b of bewerbungen) {
if (b.status === 'Vorstellungsgespräch' || b.status === 'Einstellung') stats.positiv++;
else if (b.status === 'Absage') stats.absagen++;
else stats.ausstehend++;
}
// Years for filter dropdown
const dbJahre = db.prepare(
`SELECT DISTINCT strftime('%Y', datum) AS j FROM bewerbungen ORDER BY j DESC`
).all().map(r => parseInt(r.j)).filter(Boolean);
const jahre = [...new Set([...dbJahre, now.getFullYear()])].sort((a, b) => b - a);
res.render('index', {
bewerbungen,
bewerbungenJson: safeJson(bewerbungen),
stats,
monat,
jahr,
monate: MONATE,
jahre,
artOptionen: ART_OPTIONEN,
statusOptionen: STATUS_OPTIONEN,
settings: getSettings(),
monatName: MONATE[monat - 1],
fehler: req.query.fehler || null,
currentPage: 'uebersicht'
});
});
// POST /bewerbungen Neu anlegen
app.post('/bewerbungen', (req, res) => {
const { datum, firma, stelle, art, status, notizen } = req.body;
if (!datum || !firma || !stelle) {
const d = new Date();
return res.redirect(`/?fehler=pflichtfelder&monat=${d.getMonth() + 1}&jahr=${d.getFullYear()}`);
}
db.prepare(`
INSERT INTO bewerbungen (datum, firma, stelle, art, status, notizen)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
sanitize(datum, 20),
sanitize(firma),
sanitize(stelle),
sanitize(art),
sanitize(status),
sanitize(notizen)
);
const d = new Date(datum + 'T00:00:00');
res.redirect(`/?monat=${d.getMonth() + 1}&jahr=${d.getFullYear()}`);
});
// PUT /bewerbungen/:id Aktualisieren
app.put('/bewerbungen/:id', (req, res) => {
const id = parseInt(req.params.id);
const { datum, firma, stelle, art, status, notizen, monat, jahr } = req.body;
if (!datum || !firma || !stelle) {
return res.redirect(`/?fehler=pflichtfelder&monat=${monat}&jahr=${jahr}`);
}
db.prepare(`
UPDATE bewerbungen
SET datum=?, firma=?, stelle=?, art=?, status=?, notizen=?, updated_at=CURRENT_TIMESTAMP
WHERE id=?
`).run(
sanitize(datum, 20),
sanitize(firma),
sanitize(stelle),
sanitize(art),
sanitize(status),
sanitize(notizen),
id
);
res.redirect(`/?monat=${monat || 1}&jahr=${jahr || new Date().getFullYear()}`);
});
// DELETE /bewerbungen/:id Löschen
app.delete('/bewerbungen/:id', (req, res) => {
db.prepare('DELETE FROM bewerbungen WHERE id=?').run(parseInt(req.params.id));
const { monat, jahr } = req.body;
const now = new Date();
res.redirect(`/?monat=${monat || now.getMonth() + 1}&jahr=${jahr || now.getFullYear()}`);
});
// GET /einstellungen
app.get('/einstellungen', (req, res) => {
res.render('einstellungen', {
settings: getSettings(),
gespeichert: req.query.gespeichert === '1',
currentPage: 'einstellungen'
});
});
// POST /einstellungen
app.post('/einstellungen', (req, res) => {
const { name, adresse, kundennummer } = req.body;
db.prepare(`
INSERT OR REPLACE INTO settings (id, name, adresse, kundennummer)
VALUES (1, ?, ?, ?)
`).run(sanitize(name), sanitize(adresse), sanitize(kundennummer));
res.redirect('/einstellungen?gespeichert=1');
});
// GET /api/pdf-daten JSON für clientseitige PDF-Generierung
app.get('/api/pdf-daten', (req, res) => {
const monat = Math.min(12, Math.max(1, parseInt(req.query.monat) || 1));
const jahr = parseInt(req.query.jahr) || new Date().getFullYear();
const ms = String(monat).padStart(2, '0');
const bewerbungen = db.prepare(`
SELECT * FROM bewerbungen
WHERE datum BETWEEN ? AND ?
ORDER BY datum ASC, id ASC
`).all(`${jahr}-${ms}-01`, `${jahr}-${ms}-31`);
res.json({ bewerbungen, settings: getSettings(), monat, jahr });
});
// ── Start ─────────────────────────────────────────────────────────────────────
app.listen(PORT, () => {
console.log(`\n✓ Bewerbungs-Tracker läuft auf → http://localhost:${PORT}\n`);
});