From 5ce835ff3a7b8d77e7a9f7ead61cd1d26c00d9e0 Mon Sep 17 00:00:00 2001 From: Thomas Hackner Date: Tue, 14 Jul 2026 11:10:02 +0200 Subject: [PATCH] Bewerbungsstatus "Vertragsverhandlung" ergaenzt Neue Station zwischen Vorstellungsgespraech und Einstellung - in der Statusliste, der Gruppierung auf der Startseite, den Farbskalen (Lime, zwischen dem Gelb des Gespraechs und dem Gruen der Einstellung), im PDF-Export, in der Statistik und in der REST-API/OpenAPI. Der Statistik-Funnel zaehlte bisher ever('Vorstellungsgespraech') + ever('Einstellung') und damit eine Bewerbung, die beide Stationen durchlaufen hat, doppelt. Mit einer weiteren Stufe waere das schlimmer geworden, deshalb zaehlt der Funnel jetzt pro Bewerbung statt pro Statuszeile: jede Stufe zaehlt eine Bewerbung genau einmal, ein spaeterer Status impliziert die frueheren. Co-Authored-By: Claude Opus 4.8 --- README.md | 7 +++++++ lib/api.js | 3 ++- lib/openapi.js | 3 ++- public/js/main.js | 3 ++- server.js | 35 ++++++++++++++++++++++++++++++----- views/bewerbung.ejs | 1 + views/index.ejs | 4 +++- views/statistik.ejs | 4 ++-- 8 files changed, 49 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 02e882d..4d0aba3 100644 --- a/README.md +++ b/README.md @@ -162,10 +162,17 @@ bewerbungs-tracker/ - Sonstiges ### Status +- Entwurf - Gesendet - Eingangsbestätigung +- In Bearbeitung +- Interessiert +- Warten auf Rückmeldung +- Warten auf meine Antwort - Vorstellungsgespräch +- Vertragsverhandlung - Absage +- Absage von meiner Seite - Einstellung - Keine Rückmeldung diff --git a/lib/api.js b/lib/api.js index f88ebd5..b7e5eb0 100644 --- a/lib/api.js +++ b/lib/api.js @@ -27,7 +27,8 @@ function withLabels(row) { const STATUS_OPTIONS = [ 'Entwurf', 'Gesendet', 'Eingangsbestätigung', 'In Bearbeitung', 'Interessiert', 'Warten auf Rückmeldung', 'Warten auf meine Antwort', - 'Vorstellungsgespräch', 'Absage', 'Absage von meiner Seite', 'Einstellung', 'Keine Rückmeldung', + 'Vorstellungsgespräch', 'Vertragsverhandlung', + 'Absage', 'Absage von meiner Seite', 'Einstellung', 'Keine Rückmeldung', ]; function createExternalApi(deps) { diff --git a/lib/openapi.js b/lib/openapi.js index 110e220..9d09b62 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -10,7 +10,8 @@ const ART_OPTIONS = [ const STATUS_OPTIONS = [ 'Entwurf', 'Gesendet', 'Eingangsbestätigung', 'In Bearbeitung', 'Interessiert', 'Warten auf Rückmeldung', 'Warten auf meine Antwort', - 'Vorstellungsgespräch', 'Absage', 'Absage von meiner Seite', 'Einstellung', 'Keine Rückmeldung', + 'Vorstellungsgespräch', 'Vertragsverhandlung', + 'Absage', 'Absage von meiner Seite', 'Einstellung', 'Keine Rückmeldung', ]; const { LABEL_OPTIONS } = require('./labels'); diff --git a/public/js/main.js b/public/js/main.js index 1cc7c08..47a30df 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -356,7 +356,7 @@ function generatePdfDocument(settings, applications, month, year) { // ---- Group entries by status (order kept in sync with views/index.ejs) ---- const statusOrder = ['Entwurf', 'Gesendet', 'Eingangsbestätigung', 'In Bearbeitung', 'Interessiert', 'Warten auf Rückmeldung', 'Warten auf meine Antwort', - 'Vorstellungsgespräch', 'Einstellung', 'Absage', 'Absage von meiner Seite', 'Keine Rückmeldung']; + 'Vorstellungsgespräch', 'Vertragsverhandlung', 'Einstellung', 'Absage', 'Absage von meiner Seite', 'Keine Rückmeldung']; // Accent colour per status (RGB) – reused by the chart and the list headings const statusColors = { 'Entwurf': [168, 85, 247], @@ -367,6 +367,7 @@ function generatePdfDocument(settings, applications, month, year) { 'Warten auf Rückmeldung': [249, 115, 22], 'Warten auf meine Antwort': [217, 70, 239], 'Vorstellungsgespräch': [245, 158, 11], + 'Vertragsverhandlung': [132, 204, 22], 'Einstellung': [34, 197, 94], 'Absage': [239, 68, 68], 'Absage von meiner Seite': [225, 29, 72], diff --git a/server.js b/server.js index 7488060..93cbdb3 100644 --- a/server.js +++ b/server.js @@ -62,7 +62,8 @@ const ART_OPTIONS = [ const STATUS_OPTIONS = [ 'Entwurf', 'Gesendet', 'Eingangsbestätigung', 'In Bearbeitung', 'Interessiert', 'Warten auf Rückmeldung', 'Warten auf meine Antwort', - 'Vorstellungsgespräch', 'Absage', 'Absage von meiner Seite', 'Einstellung', 'Keine Rückmeldung' + 'Vorstellungsgespräch', 'Vertragsverhandlung', + 'Absage', 'Absage von meiner Seite', 'Einstellung', 'Keine Rückmeldung' ]; // Base document types the user can provide as a foundation for AI tailoring const BASIS_TYP_OPTIONS = ['Anschreiben', 'Lebenslauf', 'Profil/Kurzprofil', 'Sonstiges']; @@ -1745,6 +1746,27 @@ initializeDatabase().then(async () => { const ever = (s) => verlaufMap[s] || 0; const cur = (s) => statusMap[s] || 0; + // Für den Funnel: welche Stationen hat JEDE Bewerbung durchlaufen. Eine + // Stufe zählt eine Bewerbung genau einmal — ein späterer Status impliziert + // die früheren (eine Einstellung ist auch ein geführtes Gespräch), und ein + // Aufsummieren von ever() würde eine Bewerbung, die beide Stationen hat, + // doppelt zählen. + const stationen = new Map(); + const verlaufPaare = await dbAll( + 'SELECT DISTINCT bewerbung_id, status FROM status_verlauf WHERE user_id = ?', [U] + ); + verlaufPaare.forEach((r) => { + if (!stationen.has(r.bewerbung_id)) stationen.set(r.bewerbung_id, new Set()); + stationen.get(r.bewerbung_id).add(r.status); + }); + const everAny = (...statusListe) => { + let n = 0; + for (const menge of stationen.values()) { + if (statusListe.some((s) => menge.has(s))) n++; + } + return n; + }; + const entwuerfe = cur('Entwurf'); const gesendet = total - entwuerfe; const gespraech = cur('Vorstellungsgespräch'); @@ -1755,7 +1777,8 @@ initializeDatabase().then(async () => { const rueckStatus = [ 'Eingangsbestätigung', 'In Bearbeitung', 'Interessiert', 'Warten auf Rückmeldung', 'Warten auf meine Antwort', - 'Vorstellungsgespräch', 'Absage', 'Absage von meiner Seite', 'Einstellung' + 'Vorstellungsgespräch', 'Vertragsverhandlung', + 'Absage', 'Absage von meiner Seite', 'Einstellung' ]; const antworten = rueckStatus.reduce((a, s) => a + cur(s), 0); @@ -1763,13 +1786,15 @@ initializeDatabase().then(async () => { 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. + // Funnel: Bewerbungen, die die Stufe je erreicht haben. Ein späterer Status + // impliziert die früheren — wer eingestellt wurde, hat auch verhandelt und + // ein Gespräch geführt, selbst wenn diese Zwischenschritte nie erfasst wurden. 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: 'Vorstellungsgespräch', count: everAny('Vorstellungsgespräch', 'Vertragsverhandlung', 'Einstellung') }, + { label: 'Vertragsverhandlung', count: everAny('Vertragsverhandlung', 'Einstellung') }, { label: 'Einstellung', count: ever('Einstellung') } ]; diff --git a/views/bewerbung.ejs b/views/bewerbung.ejs index 7feca6b..95a64d3 100644 --- a/views/bewerbung.ejs +++ b/views/bewerbung.ejs @@ -16,6 +16,7 @@ 'Warten auf Rückmeldung': 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200', 'Warten auf meine Antwort': 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-900 dark:text-fuchsia-200', 'Vorstellungsgespräch': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', + 'Vertragsverhandlung': 'bg-lime-100 text-lime-800 dark:bg-lime-900 dark:text-lime-200', 'Einstellung': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', 'Absage': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', 'Absage von meiner Seite': 'bg-rose-100 text-rose-800 dark:bg-rose-900 dark:text-rose-200', diff --git a/views/index.ejs b/views/index.ejs index 8117be2..d9e8b43 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -147,7 +147,7 @@ // Status-Reihenfolge synchron zu public/js/main.js halten const statusOrder = ['Entwurf', 'Gesendet', 'Eingangsbestätigung', 'In Bearbeitung', 'Interessiert', 'Warten auf Rückmeldung', 'Warten auf meine Antwort', - 'Vorstellungsgespräch', 'Einstellung', 'Absage', 'Absage von meiner Seite', 'Keine Rückmeldung']; + 'Vorstellungsgespräch', 'Vertragsverhandlung', 'Einstellung', 'Absage', 'Absage von meiner Seite', 'Keine Rückmeldung']; const statusDot = { 'Entwurf': 'bg-purple-500', 'Gesendet': 'bg-indigo-500', @@ -157,6 +157,7 @@ 'Warten auf Rückmeldung': 'bg-orange-500', 'Warten auf meine Antwort': 'bg-fuchsia-500', 'Vorstellungsgespräch': 'bg-yellow-500', + 'Vertragsverhandlung': 'bg-lime-500', 'Einstellung': 'bg-green-500', 'Absage': 'bg-red-500', 'Absage von meiner Seite': 'bg-rose-500', @@ -219,6 +220,7 @@ app.status === 'Absage' ? 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100' : app.status === 'Absage von meiner Seite' ? 'bg-rose-100 text-rose-800 dark:bg-rose-800 dark:text-rose-100' : app.status === 'Vorstellungsgespräch' ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100' : + app.status === 'Vertragsverhandlung' ? 'bg-lime-100 text-lime-800 dark:bg-lime-800 dark:text-lime-100' : app.status === 'In Bearbeitung' ? 'bg-teal-100 text-teal-800 dark:bg-teal-800 dark:text-teal-100' : app.status === 'Interessiert' ? 'bg-pink-100 text-pink-800 dark:bg-pink-800 dark:text-pink-100' : app.status === 'Warten auf Rückmeldung' ? 'bg-orange-100 text-orange-800 dark:bg-orange-800 dark:text-orange-100' : diff --git a/views/statistik.ejs b/views/statistik.ejs index 51672bb..015f136 100644 --- a/views/statistik.ejs +++ b/views/statistik.ejs @@ -239,7 +239,7 @@ 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', + 'Vorstellungsgespräch':'#10b981','Vertragsverhandlung':'#84cc16','Absage':'#f43f5e','Absage von meiner Seite':'#f97316', 'Einstellung':'#059669','Keine Rückmeldung':'#cbd5e1' }[s] || '#2563eb'); %> @@ -313,7 +313,7 @@ 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', + 'Vorstellungsgespräch':'#10b981','Vertragsverhandlung':'#84cc16','Absage':'#f43f5e','Absage von meiner Seite':'#f97316', 'Einstellung':'#059669','Keine Rückmeldung':'#cbd5e1' }[s] || '#2563eb'); %>