Files
jobbi-bewerbung/views/statistik.ejs
T
thomasandClaude Opus 4.8 9fc4225204 Aktivitaeten: eigene Seite mit Pagination, kaputte Eintragslinks repariert
Der Link unter "Letzte Aktivitaeten" heisst jetzt "Alle" und fuehrt auf
/aktivitaeten - den vollstaendigen Verlauf ueber alle Bewerbungen, 25 Eintraege
je Seite. Die Blaetterleiste sind echte Links (kein JS), damit Zurueck-Taste,
Lesezeichen und Neuer-Tab funktionieren; eine Seitenzahl ausserhalb des Bereichs
wird auf den gueltigen Rand geklemmt statt eine leere Seite zu zeigen.

Dabei ein Fehler von mir aus dem letzten Commit behoben: Ich hatte den Tag-Namen
samt href per <%= %> zusammengesetzt, was die Anfuehrungszeichen escaped -
heraus kam href="&quot;/bewerbung/121&quot;" und damit die URL
/%22/bewerbung/121%22. Jetzt zwei ausgeschriebene Zweige (Link bzw. Block).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 11:42:02 +02:00

350 lines
22 KiB
Plaintext

<!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','Vertragsverhandlung':'#84cc16','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 id="aktivitaet" class="text-gray-900 dark:text-white">Letzte Aktivität</h2>
<p class="sub mb-5">
Die jüngsten Statusänderungen deiner Bewerbungen.
<a href="/aktivitaeten" class="text-blue-600 dark:text-sky-400 hover:underline">Alle</a>
</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','Vertragsverhandlung':'#84cc16','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>