Statistik auf eigene Seite ausgelagert, umfangreich und modern
- Eigene Route /statistik mit umfassender Auswertung pro Benutzer - KPI-Karten, Monats-Trend (SVG), Verteilung nach Status/Art/Ort, Bewerbungs-Funnel und letzte Aktivitaet als Timeline - Statistik-Eintrag in der Top-Navigation (Desktop + Mobil) - Kurzsstatistik vom Dashboard entfernt Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1409,6 +1409,123 @@ initializeDatabase().then(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Statistik — comprehensive, on its own page. The dashboard only carries a
|
||||
// quick glance now; everything that needs room to breathe lives here.
|
||||
app.get('/statistik', async (req, res) => {
|
||||
try {
|
||||
const U = uid();
|
||||
|
||||
const totalRow = await dbGet('SELECT COUNT(*) as count FROM bewerbungen WHERE user_id = ?', [U]);
|
||||
const total = totalRow ? totalRow.count : 0;
|
||||
|
||||
const byArt = await dbAll(`
|
||||
SELECT art, COUNT(*) as count FROM bewerbungen
|
||||
WHERE user_id = ? AND art IS NOT NULL AND art != ''
|
||||
GROUP BY art ORDER BY count DESC
|
||||
`, [U]);
|
||||
|
||||
const statusRows = await dbAll(`
|
||||
SELECT status, COUNT(*) as count FROM bewerbungen
|
||||
WHERE user_id = ? AND status IS NOT NULL AND status != ''
|
||||
GROUP BY status
|
||||
`, [U]);
|
||||
const statusMap = {};
|
||||
statusRows.forEach((r) => { statusMap[r.status] = r.count; });
|
||||
// Preserve the canonical STATUS_OPTIONS order so the bars read as a funnel
|
||||
// top-to-bottom rather than a noisy rank-by-count.
|
||||
const byStatus = STATUS_OPTIONS
|
||||
.map((s) => ({ status: s, count: statusMap[s] || 0 }))
|
||||
.filter((x) => x.count > 0);
|
||||
|
||||
const byOrt = await dbAll(`
|
||||
SELECT ort, COUNT(*) as count FROM bewerbungen
|
||||
WHERE user_id = ? AND ort IS NOT NULL AND ort != ''
|
||||
GROUP BY ort ORDER BY count DESC LIMIT 10
|
||||
`, [U]);
|
||||
|
||||
// Monthly trend (oldest first so the chart reads left-to-right).
|
||||
const byMonth = await dbAll(`
|
||||
SELECT strftime('%Y-%m', datum) as ym, COUNT(*) as count
|
||||
FROM bewerbungen WHERE user_id = ? AND datum IS NOT NULL
|
||||
GROUP BY ym ORDER BY ym ASC
|
||||
`, [U]);
|
||||
|
||||
// How many applications ever reached each status — drives the funnel.
|
||||
const verlaufStatus = await dbAll(`
|
||||
SELECT status, COUNT(DISTINCT bewerbung_id) as count
|
||||
FROM status_verlauf WHERE user_id = ? GROUP BY status
|
||||
`, [U]);
|
||||
const verlaufMap = {};
|
||||
verlaufStatus.forEach((r) => { verlaufMap[r.status] = r.count; });
|
||||
const ever = (s) => verlaufMap[s] || 0;
|
||||
const cur = (s) => statusMap[s] || 0;
|
||||
|
||||
const entwuerfe = cur('Entwurf');
|
||||
const gesendet = total - entwuerfe;
|
||||
const gespraech = cur('Vorstellungsgespräch');
|
||||
const eingestellt = cur('Einstellung');
|
||||
const absagen = cur('Absage') + cur('Absage von meiner Seite');
|
||||
|
||||
// Any reply at all — everything past "Gesendet" except "Keine Rückmeldung".
|
||||
const rueckStatus = [
|
||||
'Eingangsbestätigung', 'In Bearbeitung', 'Interessiert',
|
||||
'Warten auf Rückmeldung', 'Warten auf meine Antwort',
|
||||
'Vorstellungsgespräch', 'Absage', 'Absage von meiner Seite', 'Einstellung'
|
||||
];
|
||||
const antworten = rueckStatus.reduce((a, s) => a + cur(s), 0);
|
||||
|
||||
const erfolgsquote = gesendet > 0 ? Math.round((eingestellt / gesendet) * 100) : 0;
|
||||
const antwortquote = gesendet > 0 ? Math.round((antworten / gesendet) * 100) : 0;
|
||||
const avgProMonat = byMonth.length > 0 ? Math.round((total / byMonth.length) * 10) / 10 : 0;
|
||||
|
||||
// Funnel: distinct applications that ever reached the stage. Einstellung
|
||||
// implies a Gespräch happened, so count those toward the Gespräch stage too.
|
||||
const funnel = [
|
||||
{ label: 'Bewerbungen gesamt', count: total },
|
||||
{ label: 'Versendet', count: ever('Gesendet') || gesendet },
|
||||
{ label: 'Eingangsbestätigung', count: ever('Eingangsbestätigung') },
|
||||
{ label: 'Vorstellungsgespräch', count: ever('Vorstellungsgespräch') + ever('Einstellung') },
|
||||
{ label: 'Einstellung', count: ever('Einstellung') }
|
||||
];
|
||||
|
||||
// Recent activity: latest status changes with the application they belong to.
|
||||
const aktivitaet = await dbAll(`
|
||||
SELECT sv.datum, sv.status, sv.kommentar, b.id as bewerbung_id,
|
||||
b.firma, b.stelle
|
||||
FROM status_verlauf sv
|
||||
LEFT JOIN bewerbungen b ON b.id = sv.bewerbung_id
|
||||
WHERE sv.user_id = ?
|
||||
ORDER BY datetime(sv.created_at) DESC, sv.id DESC
|
||||
LIMIT 12
|
||||
`, [U]);
|
||||
|
||||
res.render('statistik', {
|
||||
total,
|
||||
byArt,
|
||||
byStatus,
|
||||
byOrt,
|
||||
byMonth,
|
||||
kpis: {
|
||||
gesendet,
|
||||
gespraech,
|
||||
eingestellt,
|
||||
absagen,
|
||||
antworten,
|
||||
erfolgsquote,
|
||||
antwortquote,
|
||||
avgProMonat,
|
||||
monateAktiv: byMonth.length
|
||||
},
|
||||
funnel,
|
||||
aktivitaet,
|
||||
statusOptions: STATUS_OPTIONS
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading statistik:', error);
|
||||
res.status(500).send('Serverfehler');
|
||||
}
|
||||
});
|
||||
|
||||
// Get single application
|
||||
app.get('/api/bewerbungen/:id', async (req, res) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user