Refactor UI/views, rework Docker build, untrack local data
- Views umstrukturiert: einstellungen.ejs -> bewerbung.ejs, neues partials/head.ejs, header/footer/index angepasst - CSS umbenannt: style.css -> styles.css - server.js und public/js/main.js ueberarbeitet - Dockerfile auf schlankes Multi-Stage-Setup umgestellt; docker-compose.yml und .dockerignore entfernt - npm-Scripts docker:build/push/deploy ergaenzt - SQLite-DB und .idea aus Git entfernt und via .gitignore ignoriert Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,234 +1,497 @@
|
||||
'use strict';
|
||||
const express = require('express');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
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 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'
|
||||
// Shared option lists (used in multiple views)
|
||||
const ART_OPTIONS = [
|
||||
'E-Mail', 'Online-Portal', 'Indeed', 'StepStone',
|
||||
'Firmenwebsite', 'Post', 'Initiativbewerbung',
|
||||
'Arbeitsagentur', 'Sonstiges'
|
||||
];
|
||||
|
||||
const STATUS_OPTIONEN = [
|
||||
const STATUS_OPTIONS = [
|
||||
'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 }));
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
app.use(methodOverride('_method'));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
// Set EJS as template engine
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
|
||||
// ── Routes ────────────────────────────────────────────────────────────────────
|
||||
// Ensure data directory exists
|
||||
const dataDir = path.join(__dirname, 'data');
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 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();
|
||||
// Database setup
|
||||
const dbPath = path.join(dataDir, 'bewerbungen.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
const monatStr = String(monat).padStart(2, '0');
|
||||
const vonDatum = `${jahr}-${monatStr}-01`;
|
||||
const bisDatum = `${jahr}-${monatStr}-31`;
|
||||
// Sanitize input to prevent XSS
|
||||
function sanitizeInput(input) {
|
||||
if (typeof input !== 'string') return input;
|
||||
return input
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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'
|
||||
// Promise wrapper for db operations
|
||||
function dbGet(sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(sql, params, (err, result) => {
|
||||
if (err) reject(err);
|
||||
else resolve(result);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 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'
|
||||
function dbAll(sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (err, results) => {
|
||||
if (err) reject(err);
|
||||
else resolve(results);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function dbRun(sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, function(err) {
|
||||
if (err) reject(err);
|
||||
else resolve({ lastID: this.lastID, changes: this.changes });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Recompute an application's current status from its latest timeline entry
|
||||
async function syncCurrentStatus(bewerbungId) {
|
||||
const latest = await dbGet(
|
||||
'SELECT status FROM status_verlauf WHERE bewerbung_id = ? ORDER BY date(datum) DESC, id DESC LIMIT 1',
|
||||
[bewerbungId]
|
||||
);
|
||||
await dbRun(
|
||||
'UPDATE bewerbungen SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[latest ? latest.status : '', bewerbungId]
|
||||
);
|
||||
}
|
||||
|
||||
// Attach the status timeline to each application (single query, grouped in JS)
|
||||
async function attachVerlauf(applications) {
|
||||
if (!applications.length) return applications;
|
||||
const all = await dbAll('SELECT * FROM status_verlauf ORDER BY date(datum) ASC, id ASC');
|
||||
const byApp = {};
|
||||
all.forEach((v) => { (byApp[v.bewerbung_id] = byApp[v.bewerbung_id] || []).push(v); });
|
||||
applications.forEach((a) => { a.verlauf = byApp[a.id] || []; });
|
||||
return applications;
|
||||
}
|
||||
|
||||
// Initialize database - create tables and default settings in one operation
|
||||
function initializeDatabase() {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.serialize(() => {
|
||||
db.run('PRAGMA foreign_keys = ON');
|
||||
|
||||
// Create tables
|
||||
db.run(`
|
||||
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,
|
||||
interne_notizen TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
// Migration: add interne_notizen to pre-existing databases (ignore "duplicate column")
|
||||
db.run('ALTER TABLE bewerbungen ADD COLUMN interne_notizen TEXT', () => {
|
||||
|
||||
// Chronological status changes, each with an optional comment
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS status_verlauf (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bewerbung_id INTEGER NOT NULL,
|
||||
datum DATE NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
kommentar TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
name TEXT,
|
||||
adresse TEXT,
|
||||
kundennummer TEXT
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
// Insert default settings if not exists
|
||||
db.get('SELECT COUNT(*) as count FROM settings WHERE id = 1', (err, result) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
if (result && result.count === 0) {
|
||||
db.run(
|
||||
'INSERT INTO settings (id, name, adresse, kundennummer) VALUES (1, ?, ?, ?)',
|
||||
['Max Mustermann', 'Musterstraße 1, 12345 Musterstadt', ''],
|
||||
(err) => {
|
||||
if (err) return reject(err);
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize and start server
|
||||
initializeDatabase().then(() => {
|
||||
console.log('Database initialized successfully');
|
||||
|
||||
// Routes
|
||||
app.get('/', async (req, res) => {
|
||||
try {
|
||||
const { month, year } = req.query;
|
||||
|
||||
let query = 'SELECT * FROM bewerbungen ORDER BY datum DESC, created_at DESC';
|
||||
const params = [];
|
||||
|
||||
if (month && year) {
|
||||
query = 'SELECT * FROM bewerbungen WHERE strftime("%m", datum) = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC, created_at DESC';
|
||||
params.push(month.padStart(2, '0'), year);
|
||||
} else if (year) {
|
||||
query = 'SELECT * FROM bewerbungen WHERE strftime("%Y", datum) = ? ORDER BY datum DESC, created_at DESC';
|
||||
params.push(year);
|
||||
}
|
||||
|
||||
const applications = await dbAll(query, params);
|
||||
await attachVerlauf(applications);
|
||||
const settings = await dbGet('SELECT * FROM settings WHERE id = 1');
|
||||
|
||||
// Get statistics
|
||||
const totalCount = await dbGet('SELECT COUNT(*) as count FROM bewerbungen');
|
||||
const byArt = await dbAll(`
|
||||
SELECT art, COUNT(*) as count FROM bewerbungen
|
||||
WHERE art IS NOT NULL AND art != ''
|
||||
GROUP BY art ORDER BY count DESC
|
||||
`);
|
||||
const byStatus = await dbAll(`
|
||||
SELECT status, COUNT(*) as count FROM bewerbungen
|
||||
WHERE status IS NOT NULL AND status != ''
|
||||
GROUP BY status ORDER BY count DESC
|
||||
`);
|
||||
|
||||
// Get available months/years for filter
|
||||
const availableMonths = await dbAll(`
|
||||
SELECT DISTINCT strftime("%Y-%m", datum) as yearmonth,
|
||||
strftime("%m", datum) as month,
|
||||
strftime("%Y", datum) as year
|
||||
FROM bewerbungen ORDER BY datum DESC
|
||||
`);
|
||||
|
||||
res.render('index', {
|
||||
applications,
|
||||
settings,
|
||||
statistics: {
|
||||
total: totalCount ? totalCount.count : 0,
|
||||
byArt,
|
||||
byStatus
|
||||
},
|
||||
availableMonths,
|
||||
currentFilter: { month, year },
|
||||
artOptions: ART_OPTIONS,
|
||||
statusOptions: STATUS_OPTIONS
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
res.status(500).send('Serverfehler');
|
||||
}
|
||||
});
|
||||
|
||||
// Get single application
|
||||
app.get('/api/bewerbungen/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
|
||||
|
||||
if (!application) {
|
||||
return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
||||
}
|
||||
|
||||
res.json(application);
|
||||
} catch (error) {
|
||||
console.error('Error getting application:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get settings
|
||||
app.get('/api/settings', async (req, res) => {
|
||||
try {
|
||||
const settings = await dbGet('SELECT * FROM settings WHERE id = 1');
|
||||
res.json(settings);
|
||||
} catch (error) {
|
||||
console.error('Error getting settings:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
// Save settings
|
||||
app.post('/api/settings', async (req, res) => {
|
||||
try {
|
||||
const { name, adresse, kundennummer } = req.body;
|
||||
|
||||
await dbRun(
|
||||
'UPDATE settings SET name = ?, adresse = ?, kundennummer = ? WHERE id = 1',
|
||||
[sanitizeInput(name), sanitizeInput(adresse), sanitizeInput(kundennummer)]
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error saving settings:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create application
|
||||
app.post('/api/bewerbungen', async (req, res) => {
|
||||
try {
|
||||
const { datum, firma, stelle, art, status, notizen, interne_notizen, kommentar } = req.body;
|
||||
|
||||
const result = await dbRun(
|
||||
'INSERT INTO bewerbungen (datum, firma, stelle, art, status, notizen, interne_notizen) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[datum, sanitizeInput(firma), sanitizeInput(stelle),
|
||||
sanitizeInput(art), sanitizeInput(status), sanitizeInput(notizen), sanitizeInput(interne_notizen)]
|
||||
);
|
||||
|
||||
// Record the initial status as the first timeline entry
|
||||
if (status && status.trim()) {
|
||||
await dbRun(
|
||||
'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)',
|
||||
[result.lastID, datum, sanitizeInput(status), sanitizeInput(kommentar || '')]
|
||||
);
|
||||
}
|
||||
|
||||
const newApplication = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [result.lastID]);
|
||||
|
||||
res.json({ success: true, application: newApplication });
|
||||
} catch (error) {
|
||||
console.error('Error creating application:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update application
|
||||
app.put('/api/bewerbungen/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { datum, firma, stelle, art, status, notizen } = req.body;
|
||||
|
||||
await dbRun(
|
||||
'UPDATE bewerbungen SET datum = ?, firma = ?, stelle = ?, art = ?, status = ?, notizen = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[datum, sanitizeInput(firma), sanitizeInput(stelle),
|
||||
sanitizeInput(art), sanitizeInput(status), sanitizeInput(notizen), id]
|
||||
);
|
||||
|
||||
const updatedApplication = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
|
||||
|
||||
res.json({ success: true, application: updatedApplication });
|
||||
} catch (error) {
|
||||
console.error('Error updating application:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete application
|
||||
app.delete('/api/bewerbungen/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await dbRun('DELETE FROM status_verlauf WHERE bewerbung_id = ?', [id]);
|
||||
await dbRun('DELETE FROM bewerbungen WHERE id = ?', [id]);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting application:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
// Applications for PDF export (optionally filtered), including the status timeline
|
||||
app.get('/api/export', async (req, res) => {
|
||||
try {
|
||||
const { month, year } = req.query;
|
||||
|
||||
let query = 'SELECT * FROM bewerbungen ORDER BY datum DESC';
|
||||
const params = [];
|
||||
|
||||
if (month && year) {
|
||||
query = 'SELECT * FROM bewerbungen WHERE strftime("%m", datum) = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC';
|
||||
params.push(month.padStart(2, '0'), year);
|
||||
} else if (year) {
|
||||
query = 'SELECT * FROM bewerbungen WHERE strftime("%Y", datum) = ? ORDER BY datum DESC';
|
||||
params.push(year);
|
||||
}
|
||||
|
||||
const applications = await dbAll(query, params);
|
||||
await attachVerlauf(applications);
|
||||
// Internal notes must never reach the PDF/export
|
||||
applications.forEach((a) => { delete a.interne_notizen; });
|
||||
|
||||
res.json(applications);
|
||||
} catch (error) {
|
||||
console.error('Error exporting applications:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
// ----- Dedicated edit page + status-timeline management -----
|
||||
|
||||
// Edit page for a single application
|
||||
app.get('/bewerbung/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
|
||||
if (!application) return res.status(404).send('Bewerbung nicht gefunden');
|
||||
|
||||
const verlauf = await dbAll(
|
||||
'SELECT * FROM status_verlauf WHERE bewerbung_id = ? ORDER BY date(datum) ASC, id ASC',
|
||||
[id]
|
||||
);
|
||||
|
||||
res.render('bewerbung', {
|
||||
application,
|
||||
verlauf,
|
||||
artOptions: ART_OPTIONS,
|
||||
statusOptions: STATUS_OPTIONS,
|
||||
hideSettings: true
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading edit page:', error);
|
||||
res.status(500).send('Serverfehler');
|
||||
}
|
||||
});
|
||||
|
||||
// Update application core data (status is managed via the timeline)
|
||||
app.post('/bewerbung/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { datum, firma, stelle, art, notizen, interne_notizen } = req.body;
|
||||
|
||||
await dbRun(
|
||||
'UPDATE bewerbungen SET datum = ?, firma = ?, stelle = ?, art = ?, notizen = ?, interne_notizen = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[datum, sanitizeInput(firma), sanitizeInput(stelle), sanitizeInput(art), sanitizeInput(notizen), sanitizeInput(interne_notizen), id]
|
||||
);
|
||||
|
||||
res.redirect('/bewerbung/' + id);
|
||||
} catch (error) {
|
||||
console.error('Error updating application:', error);
|
||||
res.status(500).send('Serverfehler');
|
||||
}
|
||||
});
|
||||
|
||||
// Add a timeline entry (status change with date + comment)
|
||||
app.post('/bewerbung/:id/verlauf', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { datum, status, kommentar } = req.body;
|
||||
|
||||
if (datum && status && status.trim()) {
|
||||
await dbRun(
|
||||
'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)',
|
||||
[id, datum, sanitizeInput(status), sanitizeInput(kommentar || '')]
|
||||
);
|
||||
await syncCurrentStatus(id);
|
||||
}
|
||||
|
||||
res.redirect('/bewerbung/' + id);
|
||||
} catch (error) {
|
||||
console.error('Error adding timeline entry:', error);
|
||||
res.status(500).send('Serverfehler');
|
||||
}
|
||||
});
|
||||
|
||||
// Update a timeline entry
|
||||
app.post('/bewerbung/:id/verlauf/:eintragId', async (req, res) => {
|
||||
try {
|
||||
const { id, eintragId } = req.params;
|
||||
const { datum, status, kommentar } = req.body;
|
||||
|
||||
if (datum && status && status.trim()) {
|
||||
await dbRun(
|
||||
'UPDATE status_verlauf SET datum = ?, status = ?, kommentar = ? WHERE id = ? AND bewerbung_id = ?',
|
||||
[datum, sanitizeInput(status), sanitizeInput(kommentar || ''), eintragId, id]
|
||||
);
|
||||
await syncCurrentStatus(id);
|
||||
}
|
||||
|
||||
res.redirect('/bewerbung/' + id);
|
||||
} catch (error) {
|
||||
console.error('Error updating timeline entry:', error);
|
||||
res.status(500).send('Serverfehler');
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a timeline entry
|
||||
app.post('/bewerbung/:id/verlauf/:eintragId/delete', async (req, res) => {
|
||||
try {
|
||||
const { id, eintragId } = req.params;
|
||||
await dbRun('DELETE FROM status_verlauf WHERE id = ? AND bewerbung_id = ?', [eintragId, id]);
|
||||
await syncCurrentStatus(id);
|
||||
res.redirect('/bewerbung/' + id);
|
||||
} catch (error) {
|
||||
console.error('Error deleting timeline entry:', error);
|
||||
res.status(500).send('Serverfehler');
|
||||
}
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server läuft auf http://localhost:${PORT}`);
|
||||
});
|
||||
|
||||
// Handle 404
|
||||
app.use((req, res) => {
|
||||
res.status(404).send('Seite nicht gefunden');
|
||||
});
|
||||
}).catch((err) => {
|
||||
console.error('Failed to initialize database:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// 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');
|
||||
// Close database on exit
|
||||
process.on('SIGINT', () => {
|
||||
db.close();
|
||||
process.exit();
|
||||
});
|
||||
|
||||
// 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`);
|
||||
process.on('SIGTERM', () => {
|
||||
db.close();
|
||||
process.exit();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user