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 {
|
||||
|
||||
@@ -130,53 +130,6 @@
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<!-- Statistics Section -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
|
||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-white mb-6">Statistik</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<!-- Total Applications -->
|
||||
<div class="bg-blue-50 dark:bg-gray-700 rounded-lg p-4 text-center">
|
||||
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400"><%= statistics.total %></div>
|
||||
<div class="text-gray-600 dark:text-gray-400 mt-2">Gesamtbewerbungen</div>
|
||||
</div>
|
||||
|
||||
<!-- By Application Type -->
|
||||
<div class="bg-green-50 dark:bg-gray-700 rounded-lg p-4">
|
||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Nach Bewerbungsart</h3>
|
||||
<div class="space-y-2">
|
||||
<% if (statistics.byArt.length > 0) { %>
|
||||
<% statistics.byArt.forEach(item => { %>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600 dark:text-gray-400"><%= item.art %></span>
|
||||
<span class="font-medium text-gray-800 dark:text-white"><%= item.count %></span>
|
||||
</div>
|
||||
<% }); %>
|
||||
<% } else { %>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Keine Daten verfügbar</p>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- By Status -->
|
||||
<div class="bg-purple-50 dark:bg-gray-700 rounded-lg p-4">
|
||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Nach Status</h3>
|
||||
<div class="space-y-2">
|
||||
<% if (statistics.byStatus.length > 0) { %>
|
||||
<% statistics.byStatus.forEach(item => { %>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600 dark:text-gray-400"><%= item.status %></span>
|
||||
<span class="font-medium text-gray-800 dark:text-white"><%= item.count %></span>
|
||||
</div>
|
||||
<% }); %>
|
||||
<% } else { %>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Keine Daten verfügbar</p>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Applications -->
|
||||
<style>
|
||||
/* Shared column grid – used by the header labels and every application line */
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
var istAngebote = (aktPfad === '/jobangebote');
|
||||
var istPostfach = (aktPfad.indexOf('/postfach') === 0);
|
||||
var istVorlagen = (aktPfad.indexOf('/vorlagen') === 0);
|
||||
var istStatistik = (aktPfad.indexOf('/statistik') === 0);
|
||||
// The search machinery: how offers get found, what was taken, what is blocked.
|
||||
var istSuchprofil = (aktPfad.indexOf('/jobsuche') === 0);
|
||||
var istUebernommen = (aktPfad.indexOf('/jobangebote/uebernommen') === 0);
|
||||
@@ -113,6 +114,11 @@
|
||||
<span>Vorlagen</span>
|
||||
<span class="nav-rail"></span>
|
||||
</a>
|
||||
|
||||
<a href="/statistik" class="nav-item <%= istStatistik ? 'is-active' : '' %>">
|
||||
<span>Statistik</span>
|
||||
<span class="nav-rail"></span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- Right cluster: notifications + account. Below lg the nav is hidden,
|
||||
@@ -221,6 +227,7 @@
|
||||
<span class="js-badge-postfach nav-badge nav-badge--red ml-auto hidden">0</span>
|
||||
</a>
|
||||
<a href="/vorlagen" class="m-item <%= istVorlagen ? 'is-active' : '' %>">Vorlagen</a>
|
||||
<a href="/statistik" class="m-item <%= istStatistik ? 'is-active' : '' %>">Statistik</a>
|
||||
</div>
|
||||
|
||||
<div class="space-y-0.5">
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<%- include('partials/head') %>
|
||||
<style>
|
||||
/* ── Statistics page — scoped chart primitives ─────────────────────────
|
||||
The cards themselves are plain Tailwind; these classes build the bars,
|
||||
the trend and the funnel so the markup stays readable. Everything keys
|
||||
off --stat-accent so a single declaration re-tints a row. */
|
||||
:root { --stat-accent: #2563eb; }
|
||||
.stat-empty {
|
||||
padding: 2.5rem 1rem;
|
||||
text-align: center;
|
||||
color: rgb(100 116 139);
|
||||
}
|
||||
.dark .stat-empty { color: rgb(148 163 184); }
|
||||
|
||||
/* KPI tile */
|
||||
.kpi {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
background: #fff;
|
||||
border: 1px solid rgb(226 232 240);
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
|
||||
padding: 1.25rem 1.25rem 1.1rem;
|
||||
transition: transform .18s ease, box-shadow .18s ease;
|
||||
}
|
||||
.kpi:hover { transform: translateY(-2px); box-shadow: 0 12px 24px -12px rgba(15,23,42,.18); }
|
||||
.dark .kpi { background: rgb(30 41 59); border-color: rgba(255,255,255,.06); }
|
||||
.kpi-ico {
|
||||
display: grid; place-items: center;
|
||||
width: 2.5rem; height: 2.5rem; border-radius: .75rem;
|
||||
color: #fff; margin-bottom: .9rem;
|
||||
}
|
||||
.kpi-val { font-size: 1.875rem; font-weight: 700; letter-spacing: -.02em; line-height: 1.1; }
|
||||
.kpi-lab { margin-top: .25rem; font-size: .8rem; color: rgb(100 116 139); }
|
||||
.dark .kpi-lab { color: rgb(148 163 184); }
|
||||
.kpi-sub { margin-top: .35rem; font-size: .72rem; font-weight: 500; }
|
||||
.kpi-glow {
|
||||
position: absolute; right: -1.5rem; top: -1.5rem;
|
||||
width: 5rem; height: 5rem; border-radius: 9999px;
|
||||
filter: blur(28px); opacity: .35;
|
||||
}
|
||||
|
||||
/* Generic card */
|
||||
.stat-card {
|
||||
border-radius: 1rem;
|
||||
background: #fff;
|
||||
border: 1px solid rgb(226 232 240);
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
|
||||
padding: 1.25rem 1.5rem 1.5rem;
|
||||
}
|
||||
.dark .stat-card { background: rgb(30 41 59); border-color: rgba(255,255,255,.06); }
|
||||
.stat-card h2 { font-size: 1rem; font-weight: 600; }
|
||||
.stat-card .sub { font-size: .78rem; color: rgb(100 116 139); margin-top: .15rem; }
|
||||
.dark .stat-card .sub { color: rgb(148 163 184); }
|
||||
|
||||
/* Horizontal bars (Art / Status / Ort) */
|
||||
.bar-row { display: grid; grid-template-columns: minmax(0,9rem) 1fr 2.5rem; align-items: center; gap: .75rem; padding: .35rem 0; }
|
||||
.bar-label { font-size: .82rem; color: rgb(71 85 105); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.dark .bar-label { color: rgb(203 213 225); }
|
||||
.bar-track { position: relative; height: .6rem; border-radius: 9999px; background: rgb(241 245 249); overflow: hidden; }
|
||||
.dark .bar-track { background: rgba(255,255,255,.07); }
|
||||
.bar-fill { position: absolute; inset: 0 auto 0 0; border-radius: 9999px; background: var(--stat-accent); width: 0; animation: bar-grow .6s cubic-bezier(.2,.8,.2,1) forwards; }
|
||||
.bar-value { font-size: .8rem; font-weight: 600; text-align: right; color: rgb(30 41 52); }
|
||||
.dark .bar-value { color: #fff; }
|
||||
@keyframes bar-grow { from { width: 0; } }
|
||||
|
||||
/* Status swatch */
|
||||
.swatch { display: inline-block; width: .55rem; height: .55rem; border-radius: 9999px; margin-right: .4rem; background: var(--stat-accent); vertical-align: middle; }
|
||||
|
||||
/* Trend (monthly) — SVG fills its box, bars keep aspect via viewBox */
|
||||
.trend-wrap { width: 100%; }
|
||||
.trend-wrap svg { width: 100%; height: auto; display: block; }
|
||||
.trend-bar { transition: opacity .15s ease; }
|
||||
.trend-bar:hover { opacity: .85; }
|
||||
.trend-axis { fill: rgb(148 163 184); font-size: 10px; }
|
||||
.dark .trend-axis { fill: rgb(100 116 139); }
|
||||
.trend-grid { stroke: rgb(226 232 240); stroke-width: 1; }
|
||||
.dark .trend-grid { stroke: rgba(255,255,255,.07); }
|
||||
|
||||
/* Funnel */
|
||||
.funnel-row { margin-bottom: .65rem; }
|
||||
.funnel-top { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: .25rem; }
|
||||
.funnel-label { font-size: .82rem; font-weight: 500; color: rgb(51 65 85); }
|
||||
.dark .funnel-label { color: rgb(203 213 225); }
|
||||
.funnel-meta { font-size: .75rem; color: rgb(100 116 139); }
|
||||
.dark .funnel-meta { color: rgb(148 163 184); }
|
||||
.funnel-track { position: relative; height: 1.85rem; border-radius: .6rem; background: rgb(241 245 249); overflow: hidden; }
|
||||
.dark .funnel-track { background: rgba(255,255,255,.06); }
|
||||
.funnel-fill { position: absolute; inset: 0 auto 0 0; border-radius: .6rem; display: grid; place-items: end; padding-right: .65rem; font-size: .8rem; font-weight: 700; color: #fff; width: 0; animation: bar-grow .7s cubic-bezier(.2,.8,.2,1) forwards; }
|
||||
.funnel-fill span { transform: translateY(-1px); }
|
||||
|
||||
/* Activity timeline */
|
||||
.tl { position: relative; padding-left: 1.5rem; }
|
||||
.tl::before { content: ''; position: absolute; left: .4rem; top: .35rem; bottom: .35rem; width: 2px; background: rgb(226 232 240); }
|
||||
.dark .tl::before { background: rgba(255,255,255,.1); }
|
||||
.tl-item { position: relative; padding: .35rem 0 .9rem; }
|
||||
.tl-item::before { content: ''; position: absolute; left: -1.15rem; top: .55rem; width: .65rem; height: .65rem; border-radius: 9999px; background: var(--stat-accent); box-shadow: 0 0 0 3px #fff; }
|
||||
.dark .tl-item::before { box-shadow: 0 0 0 3px rgb(30 41 59); }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bar-fill, .funnel-fill { animation: none; width: var(--w); }
|
||||
.kpi:hover { transform: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen flex flex-col transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
||||
<%- include('partials/header') %>
|
||||
|
||||
<main class="flex-1 container mx-auto px-4 py-8">
|
||||
<!-- Page heading -->
|
||||
<div class="mb-8 flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-gray-900 dark:text-white">Statistik</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-slate-400">Auswertung all deiner Bewerbungen — Verlauf, Erfolgsquote und Verteilungen.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 rounded-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-white/5 px-4 py-2 text-sm shadow-sm">
|
||||
<svg class="h-4 w-4 text-blue-600 dark:text-sky-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 19V6l12-3v13M9 19c0 1.66-1.34 3-3 3s-3-1.34-3-3 1.34-3 3-3 3 1.34 3 3zm12-3c0 1.66-1.34 3-3 3s-3-1.34-3-3 1.34-3 3-3 3 1.34 3 3z"></path></svg>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200"><%= total %> Bewerbungen</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if (total === 0) { %>
|
||||
<div class="stat-card">
|
||||
<div class="stat-empty">
|
||||
<svg class="mx-auto mb-3 h-10 w-10 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 13h2l3 9 4-16 3 9h6"></path></svg>
|
||||
<p class="text-base font-medium text-gray-600 dark:text-gray-300">Noch keine Bewerbungen erfasst</p>
|
||||
<p class="mt-1 text-sm">Sobald du Bewerbungen anlegst, erscheint hier deine komplette Auswertung.</p>
|
||||
</div>
|
||||
</div>
|
||||
<% } else { %>
|
||||
|
||||
<!-- KPI cards -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-4 mb-8">
|
||||
<div class="kpi">
|
||||
<div class="kpi-glow" style="background:#2563eb"></div>
|
||||
<div class="kpi-ico" style="background:linear-gradient(135deg,#3b82f6,#2563eb)">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>
|
||||
</div>
|
||||
<div class="kpi-val text-gray-900 dark:text-white"><%= total %></div>
|
||||
<div class="kpi-lab">Bewerbungen gesamt</div>
|
||||
</div>
|
||||
|
||||
<div class="kpi">
|
||||
<div class="kpi-glow" style="background:#0ea5e9"></div>
|
||||
<div class="kpi-ico" style="background:linear-gradient(135deg,#38bdf8,#0ea5e9)">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path></svg>
|
||||
</div>
|
||||
<div class="kpi-val text-gray-900 dark:text-white"><%= kpis.gesendet %></div>
|
||||
<div class="kpi-lab">Versendet</div>
|
||||
</div>
|
||||
|
||||
<div class="kpi">
|
||||
<div class="kpi-glow" style="background:#10b981"></div>
|
||||
<div class="kpi-ico" style="background:linear-gradient(135deg,#34d399,#10b981)">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||
</div>
|
||||
<div class="kpi-val text-emerald-600 dark:text-emerald-400"><%= kpis.eingestellt %></div>
|
||||
<div class="kpi-lab">Einstellungen</div>
|
||||
<div class="kpi-sub text-emerald-600/80 dark:text-emerald-400/80"><%= kpis.erfolgsquote %>% Erfolgsquote</div>
|
||||
</div>
|
||||
|
||||
<div class="kpi">
|
||||
<div class="kpi-glow" style="background:#8b5cf6"></div>
|
||||
<div class="kpi-ico" style="background:linear-gradient(135deg,#a78bfa,#8b5cf6)">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m6-2a4 4 0 100-8 4 4 0 000 8z"></path></svg>
|
||||
</div>
|
||||
<div class="kpi-val text-violet-600 dark:text-violet-300"><%= kpis.gespraech %></div>
|
||||
<div class="kpi-lab">Vorstellungsgespräche</div>
|
||||
</div>
|
||||
|
||||
<div class="kpi">
|
||||
<div class="kpi-glow" style="background:#f43f5e"></div>
|
||||
<div class="kpi-ico" style="background:linear-gradient(135deg,#fb7185,#f43f5e)">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path></svg>
|
||||
</div>
|
||||
<div class="kpi-val text-rose-600 dark:text-rose-400"><%= kpis.absagen %></div>
|
||||
<div class="kpi-lab">Absagen</div>
|
||||
<div class="kpi-sub text-gray-500 dark:text-slate-400"><%= kpis.antwortquote %>% Antwortquote</div>
|
||||
</div>
|
||||
|
||||
<div class="kpi">
|
||||
<div class="kpi-glow" style="background:#f59e0b"></div>
|
||||
<div class="kpi-ico" style="background:linear-gradient(135deg,#fbbf24,#f59e0b)">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
|
||||
</div>
|
||||
<div class="kpi-val text-gray-900 dark:text-white"><%= kpis.avgProMonat %></div>
|
||||
<div class="kpi-lab">Ø pro Monat</div>
|
||||
<div class="kpi-sub text-gray-500 dark:text-slate-400">über <%= kpis.monateAktiv %> Monate</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Monthly trend -->
|
||||
<div class="stat-card mb-8">
|
||||
<h2 class="text-gray-900 dark:text-white">Bewerbungen pro Monat</h2>
|
||||
<p class="sub mb-5">Wie viele Bewerbungen in jedem Monat erstellt wurden.</p>
|
||||
<%
|
||||
const maxM = Math.max(1, ...byMonth.map(m => m.count));
|
||||
const VB = 1000, BH = 320; // viewBox dimensions
|
||||
const padL = 28, padR = 10, padB = 26, padT = 10;
|
||||
const innerW = VB - padL - padR;
|
||||
const innerH = BH - padT - padB;
|
||||
const slot = byMonth.length ? innerW / byMonth.length : innerW;
|
||||
const bw = Math.min(slot * .62, 40);
|
||||
const monthName = (ym) => { const [, mo] = ym.split('-'); return ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'][Number(mo)-1] || ''; };
|
||||
const yearOf = (ym) => ym.split('-')[0];
|
||||
let prevYear = '';
|
||||
%>
|
||||
<div class="trend-wrap">
|
||||
<svg viewBox="0 0 <%= VB %> <%= BH %>" preserveAspectRatio="none" role="img" aria-label="Bewerbungen pro Monat">
|
||||
<% for (let gy = 1; gy <= 4; gy++) { const y = padT + innerH * (1 - gy/4); const val = Math.round(maxM * gy/4); %>
|
||||
<line class="trend-grid" x1="<%= padL %>" y1="<%= y %>" x2="<%= VB - padR %>" y2="<%= y %>"></line>
|
||||
<text class="trend-axis" x="<%= padL-6 %>" y="<%= y+3 %>" text-anchor="end"><%= val %></text>
|
||||
<% } %>
|
||||
<% byMonth.forEach((m, i) => { const h = (m.count / maxM) * innerH; const x = padL + slot * i + (slot - bw)/2; const y = padT + innerH - h; const yr = yearOf(m.ym); const showYear = yr !== prevYear; prevYear = yr; %>
|
||||
<rect class="trend-bar" x="<%= x %>" y="<%= y %>" width="<%= bw %>" height="<%= Math.max(h, 0.5) %>" rx="3" fill="url(#trendGrad)"></rect>
|
||||
<text class="trend-axis" x="<%= x + bw/2 %>" y="<%= BH - 9 %>" text-anchor="middle"><%= monthName(m.ym) %></text>
|
||||
<% if (showYear) { %><text class="trend-axis" x="<%= x + bw/2 %>" y="<%= BH - 1 %>" text-anchor="middle" style="font-size:8px;opacity:.7"><%= yr %></text><% } %>
|
||||
<title><%= m.ym %>: <%= m.count %> Bewerbung<%= m.count===1?'':'en' %></title>
|
||||
<% }); %>
|
||||
<defs>
|
||||
<linearGradient id="trendGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#3b82f6"></stop>
|
||||
<stop offset="100%" stop-color="#0ea5e9"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- By Status -->
|
||||
<div class="stat-card">
|
||||
<h2 class="text-gray-900 dark:text-white">Verteilung nach Status</h2>
|
||||
<p class="sub mb-4">Aktueller Status jeder Bewerbung.</p>
|
||||
<% if (byStatus.length) {
|
||||
const maxS = Math.max(...byStatus.map(s => s.count));
|
||||
const statusColor = (s) => ({
|
||||
'Entwurf':'#94a3b8','Gesendet':'#3b82f6','Eingangsbestätigung':'#0ea5e9','In Bearbeitung':'#6366f1',
|
||||
'Interessiert':'#8b5cf6','Warten auf Rückmeldung':'#a3a3a3','Warten auf meine Antwort':'#a3a3a3',
|
||||
'Vorstellungsgespräch':'#10b981','Absage':'#f43f5e','Absage von meiner Seite':'#f97316',
|
||||
'Einstellung':'#059669','Keine Rückmeldung':'#cbd5e1'
|
||||
}[s] || '#2563eb');
|
||||
%>
|
||||
<% byStatus.forEach(s => { const pct = Math.round((s.count / maxS) * 100); %>
|
||||
<div class="bar-row">
|
||||
<span class="bar-label"><span class="swatch" style="background:<%= statusColor(s.status) %>"></span><%= s.status %></span>
|
||||
<div class="bar-track"><div class="bar-fill" style="--w:<%= pct %>%;width:<%= pct %>%;background:<%= statusColor(s.status) %>"></div></div>
|
||||
<span class="bar-value"><%= s.count %></span>
|
||||
</div>
|
||||
<% }); %>
|
||||
<% } else { %><div class="stat-empty">Keine Daten</div><% } %>
|
||||
</div>
|
||||
|
||||
<!-- By Art -->
|
||||
<div class="stat-card">
|
||||
<h2 class="text-gray-900 dark:text-white">Verteilung nach Bewerbungsart</h2>
|
||||
<p class="sub mb-4">Über welchen Kanal die Bewerbung lief.</p>
|
||||
<% if (byArt.length) { const maxA = Math.max(...byArt.map(a => a.count)); const artColor = ['#2563eb','#0ea5e9','#10b981','#8b5cf6','#f59e0b','#f43f5e','#6366f1','#ec4899','#14b8a6']; %>
|
||||
<% byArt.forEach((a, i) => { const pct = Math.round((a.count / maxA) * 100); %>
|
||||
<div class="bar-row">
|
||||
<span class="bar-label"><%= a.art %></span>
|
||||
<div class="bar-track"><div class="bar-fill" style="--w:<%= pct %>%;width:<%= pct %>%;background:<%= artColor[i % artColor.length] %>"></div></div>
|
||||
<span class="bar-value"><%= a.count %></span>
|
||||
</div>
|
||||
<% }); %>
|
||||
<% } else { %><div class="stat-empty">Keine Daten</div><% } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- Funnel -->
|
||||
<div class="stat-card">
|
||||
<h2 class="text-gray-900 dark:text-white">Bewerbungs-Funnel</h2>
|
||||
<p class="sub mb-5">Wie viele Bewerbungen jede Stufe je erreicht haben.</p>
|
||||
<% const topF = Math.max(1, funnel[0].count);
|
||||
const fColor = ['#2563eb','#3b82f6','#0ea5e9','#10b981','#059669'];
|
||||
funnel.forEach((f, i) => { const pct = Math.round((f.count / topF) * 100); const conv = i === 0 ? '' : ' · ' + Math.round((f.count / Math.max(1, funnel[0].count) * 100)) + '%'; %>
|
||||
<div class="funnel-row">
|
||||
<div class="funnel-top">
|
||||
<span class="funnel-label"><%= f.label %></span>
|
||||
<span class="funnel-meta"><%= f.count %><%= conv %></span>
|
||||
</div>
|
||||
<div class="funnel-track">
|
||||
<div class="funnel-fill" style="--w:<%= pct %>%;width:<%= pct %>%;background:<%= fColor[i] %>"><span><%= f.count %></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<% }); %>
|
||||
</div>
|
||||
|
||||
<!-- By Ort -->
|
||||
<div class="stat-card">
|
||||
<h2 class="text-gray-900 dark:text-white">Top Standorte</h2>
|
||||
<p class="sub mb-4">Wo du dich am häufigsten bewirbst.</p>
|
||||
<% if (byOrt.length) { const maxO = Math.max(...byOrt.map(o => o.count)); const ortColor = ['#6366f1','#0ea5e9','#10b981','#f59e0b','#f43f5e','#8b5cf6','#14b8a6','#ec4899','#3b82f6','#a3a3a3']; %>
|
||||
<% byOrt.forEach((o, i) => { const pct = Math.round((o.count / maxO) * 100); %>
|
||||
<div class="bar-row" style="grid-template-columns:minmax(0,11rem) 1fr 2.5rem">
|
||||
<span class="bar-label"><%= o.ort %></span>
|
||||
<div class="bar-track"><div class="bar-fill" style="--w:<%= pct %>%;width:<%= pct %>%;background:<%= ortColor[i % ortColor.length] %>"></div></div>
|
||||
<span class="bar-value"><%= o.count %></span>
|
||||
</div>
|
||||
<% }); %>
|
||||
<% } else { %><div class="stat-empty">Keine Standorte erfasst</div><% } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent activity -->
|
||||
<div class="stat-card">
|
||||
<h2 class="text-gray-900 dark:text-white">Letzte Aktivität</h2>
|
||||
<p class="sub mb-5">Die jüngsten Statusänderungen deiner Bewerbungen.</p>
|
||||
<% if (aktivitaet.length) {
|
||||
const statusColor = (s) => ({
|
||||
'Entwurf':'#94a3b8','Gesendet':'#3b82f6','Eingangsbestätigung':'#0ea5e9','In Bearbeitung':'#6366f1',
|
||||
'Interessiert':'#8b5cf6','Warten auf Rückmeldung':'#a3a3a3','Warten auf meine Antwort':'#a3a3a3',
|
||||
'Vorstellungsgespräch':'#10b981','Absage':'#f43f5e','Absage von meiner Seite':'#f97316',
|
||||
'Einstellung':'#059669','Keine Rückmeldung':'#cbd5e1'
|
||||
}[s] || '#2563eb');
|
||||
%>
|
||||
<div class="tl">
|
||||
<% aktivitaet.forEach(a => { %>
|
||||
<div class="tl-item" style="--stat-accent:<%= statusColor(a.status) %>">
|
||||
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<% if (a.bewerbung_id) { %>
|
||||
<a href="/bewerbung/<%= a.bewerbung_id %>" class="text-sm font-medium text-gray-900 dark:text-white hover:underline"><%= a.firma || '(ohne Firma)' %></a>
|
||||
<span class="text-sm text-gray-500 dark:text-slate-400">· <%= a.stelle || '' %></span>
|
||||
<% } else { %>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Gelöschte Bewerbung</span>
|
||||
<% } %>
|
||||
</div>
|
||||
<div class="mt-0.5 flex flex-wrap items-center gap-x-2 text-sm">
|
||||
<span class="inline-flex items-center gap-1.5 font-medium" style="color:<%= statusColor(a.status) %>">
|
||||
<span class="swatch" style="margin:0"></span><%= a.status %>
|
||||
</span>
|
||||
<span class="text-gray-400 dark:text-slate-500">· <%= a.datum %></span>
|
||||
<% if (a.kommentar) { %><span class="text-gray-500 dark:text-slate-400 truncate">· <%= a.kommentar %></span><% } %>
|
||||
</div>
|
||||
</div>
|
||||
<% }); %>
|
||||
</div>
|
||||
<% } else { %><div class="stat-empty">Noch keine Statusänderungen protokolliert.</div><% } %>
|
||||
</div>
|
||||
|
||||
<% } %>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user