c65c9f1751
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
235 lines
7.8 KiB
JavaScript
235 lines
7.8 KiB
JavaScript
'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`);
|
||
});
|