From 6952309de96473d654e9f1bd7a3d4b0985df50a8 Mon Sep 17 00:00:00 2001 From: Thomas Hackner Date: Fri, 19 Jun 2026 04:32:02 +0200 Subject: [PATCH] update --- public/js/main.js | 267 ++++++++++++++++++++++++++++++++-------- views/bewerbung.ejs | 14 ++- views/index.ejs | 8 +- views/partials/head.ejs | 4 + 4 files changed, 232 insertions(+), 61 deletions(-) diff --git a/public/js/main.js b/public/js/main.js index 9d64b28..747a63b 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -50,24 +50,26 @@ function initializeDarkMode() { // Check localStorage for dark mode preference const darkMode = localStorage.getItem('darkMode'); + // The `dark` class must live on so that Tailwind's `.dark .dark:*` + // selectors apply to the itself, not just its descendants. if (darkMode === 'enabled' || (!darkMode && window.matchMedia('(prefers-color-scheme: dark)').matches)) { - body.classList.add('dark'); + document.documentElement.classList.add('dark'); sunIcon.classList.add('hidden'); moonIcon.classList.remove('hidden'); } else { - body.classList.remove('dark'); + document.documentElement.classList.remove('dark'); sunIcon.classList.remove('hidden'); moonIcon.classList.add('hidden'); } } function toggleDarkMode() { - body.classList.toggle('dark'); + document.documentElement.classList.toggle('dark'); sunIcon.classList.toggle('hidden'); moonIcon.classList.toggle('hidden'); - + // Save preference to localStorage - if (body.classList.contains('dark')) { + if (document.documentElement.classList.contains('dark')) { localStorage.setItem('darkMode', 'enabled'); } else { localStorage.setItem('darkMode', 'disabled'); @@ -275,13 +277,14 @@ function generatePDF(event) { // PDF Generation with jsPDF function generatePdfDocument(settings, applications, month, year) { - // Check if jsPDF is loaded + // The UMD build exposes the constructor as window.jspdf.jsPDF, not as a global jsPDF + const jsPDF = window.jspdf && window.jspdf.jsPDF; if (typeof jsPDF === 'undefined') { console.error('jsPDF not loaded, please wait for libraries to load'); alert('Bitte warten Sie einen Moment und versuchen Sie es erneut.'); return; } - + const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', @@ -292,7 +295,18 @@ function generatePdfDocument(settings, applications, month, year) { 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; const monthName = month ? monthNames[parseInt(month) - 1] : 'alle'; - const title = `Bewerbungsaktivitäten - ${monthName} ${year || 'Jahre'}`; + // Determine the year shown in the title. When no year was picked in the form, + // derive it from the data so the title still reads e.g. "Juni 2026". + const yearsInData = [...new Set( + applications.map((a) => new Date(a.datum).getFullYear()).filter((y) => !isNaN(y)) + )].sort((a, b) => a - b); + const displayYear = year + || (yearsInData.length === 1 ? String(yearsInData[0]) + : yearsInData.length > 1 ? `${yearsInData[0]}–${yearsInData[yearsInData.length - 1]}` : ''); + const period = month + ? `${monthName}${displayYear ? ' ' + displayYear : ''}` + : (displayYear || 'alle Zeiträume'); + const title = `Bewerbungsaktivitäten - ${period}`; const dateStr = new Date().toLocaleDateString('de-DE'); // Page geometry @@ -318,38 +332,42 @@ function generatePdfDocument(settings, applications, month, year) { doc.text(title, pageWidth / 2, yPos, { align: 'center' }); yPos += 12; - // User information + // User information. Values like the address can span multiple lines, so we + // split them and advance yPos by the actual number of rendered lines – + // otherwise the next field overlaps the wrapped text. doc.setFontSize(12); doc.setFont('helvetica', 'normal'); - if (settings && settings.name) { - doc.text(`Name: ${settings.name}`, margin, yPos); - yPos += 7; + const lineHeight = 7; + function drawField(label, value) { + if (!value) return; + const lines = doc.splitTextToSize(`${label}: ${value}`, contentWidth); + doc.text(lines, margin, yPos); + yPos += lineHeight * lines.length; } - if (settings && settings.adresse) { - doc.text(`Adresse: ${settings.adresse}`, margin, yPos); - yPos += 7; - } - if (settings && settings.kundennummer) { - doc.text(`Kundennummer: ${settings.kundennummer}`, margin, yPos); - yPos += 7; + if (settings) { + drawField('Name', settings.name); + drawField('Adresse', settings.adresse); + drawField('Kundennummer', settings.kundennummer); } - yPos += 5; + yPos += 6; - // Summary text - const totalApplications = applications.length; - const summaryText = year - ? `Im Monat ${monthName} ${year} habe ich mich insgesamt auf ${totalApplications} Stellen beworben.` - : `Insgesamt habe ich mich auf ${totalApplications} Stellen beworben.`; - - doc.setFont('helvetica', 'bold'); - doc.text(summaryText, pageWidth / 2, yPos, { align: 'center' }); - - yPos += 12; - - // Group entries by status (keep this order in sync with views/index.ejs) + // ---- Group entries by status (order kept in sync with views/index.ejs) ---- const statusOrder = ['Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch', 'Einstellung', 'Absage', 'Keine Rückmeldung']; + // Accent colour per status (RGB) – reused by the chart and the list headings + const statusColors = { + 'Gesendet': [59, 130, 246], + 'Eingangsbestätigung': [14, 165, 233], + 'Vorstellungsgespräch': [245, 158, 11], + 'Einstellung': [34, 197, 94], + 'Absage': [239, 68, 68], + 'Keine Rückmeldung': [107, 114, 128], + 'Ohne Status': [148, 163, 184] + }; + const fallbackColor = [99, 102, 241]; + const colorFor = (s) => statusColors[s] || fallbackColor; + const groups = {}; applications.forEach((app) => { const key = (app.status && app.status.trim()) ? app.status.trim() : 'Ohne Status'; @@ -360,6 +378,150 @@ function generatePdfDocument(settings, applications, month, year) { ...Object.keys(groups).filter((k) => !statusOrder.includes(k)) ]; + const totalApplications = applications.length; + const countOf = (s) => (groups[s] ? groups[s].length : 0); + const pct = (n) => (totalApplications ? Math.round((n / totalApplications) * 100) : 0); + + // ============================================================ + // Statistics overview: KPI cards + distribution charts + // ============================================================ + function sectionHeading(text) { + ensureSpace(12); + doc.setFont('helvetica', 'bold'); + doc.setFontSize(12); + doc.setTextColor(31, 41, 55); + doc.text(text, margin, yPos); + yPos += 2.5; + doc.setDrawColor(209, 213, 219); + doc.setLineWidth(0.4); + doc.line(margin, yPos, margin + contentWidth, yPos); + yPos += 6; + } + + sectionHeading('Übersicht'); + + // --- KPI cards --- + const answered = totalApplications - countOf('Gesendet') + - countOf('Keine Rückmeldung') - countOf('Ohne Status'); + const kpis = [ + { label: 'Bewerbungen', value: String(totalApplications), color: [55, 65, 81] }, + { label: 'Gespräche', value: String(countOf('Vorstellungsgespräch')), color: statusColors['Vorstellungsgespräch'] }, + { label: 'Einstellungen', value: String(countOf('Einstellung')), color: statusColors['Einstellung'] }, + { label: 'Absagen', value: String(countOf('Absage')), color: statusColors['Absage'] }, + { label: 'Antwortquote', value: pct(answered) + '%', color: [59, 130, 246] } + ]; + const cardGap = 3.5; + const cardW = (contentWidth - cardGap * (kpis.length - 1)) / kpis.length; + const cardH = 22; + ensureSpace(cardH + 4); + const cardTop = yPos; + kpis.forEach((kpi, i) => { + const x = margin + i * (cardW + cardGap); + doc.setFillColor(248, 250, 252); + doc.roundedRect(x, cardTop, cardW, cardH, 2, 2, 'F'); + doc.setFont('helvetica', 'bold'); + doc.setFontSize(19); + doc.setTextColor(kpi.color[0], kpi.color[1], kpi.color[2]); + doc.text(kpi.value, x + cardW / 2, cardTop + 11, { align: 'center' }); + doc.setFillColor(kpi.color[0], kpi.color[1], kpi.color[2]); + doc.roundedRect(x + cardW / 2 - 5, cardTop + 13.5, 10, 1.1, 0.5, 0.5, 'F'); + doc.setFont('helvetica', 'normal'); + doc.setFontSize(8); + doc.setTextColor(107, 114, 128); + doc.text(kpi.label, x + cardW / 2, cardTop + 18.5, { align: 'center' }); + }); + yPos = cardTop + cardH + 10; + + // --- Status distribution as a horizontal bar chart --- + if (totalApplications > 0) { + sectionHeading('Status-Verteilung'); + const maxCount = Math.max(...orderedKeys.map((k) => groups[k].length)); + const labelW = 46; + const valueW = 24; + const trackX = margin + labelW; + const trackW = contentWidth - labelW - valueW; + const rowH = 8; + orderedKeys.forEach((key) => { + const count = groups[key].length; + ensureSpace(rowH); + const barY = yPos + 1.4; + const barH = 5; + doc.setFont('helvetica', 'normal'); + doc.setFontSize(9); + doc.setTextColor(55, 65, 81); + doc.text(doc.splitTextToSize(key, labelW - 3)[0], margin, barY + barH - 1.2); + doc.setFillColor(237, 240, 244); + doc.roundedRect(trackX, barY, trackW, barH, 1, 1, 'F'); + const c = colorFor(key); + const w = maxCount ? Math.max(1.5, (count / maxCount) * trackW) : 1.5; + doc.setFillColor(c[0], c[1], c[2]); + doc.roundedRect(trackX, barY, w, barH, 1, 1, 'F'); + doc.setFont('helvetica', 'bold'); + doc.setFontSize(8.5); + doc.setTextColor(55, 65, 81); + doc.text(`${count} (${pct(count)}%)`, margin + contentWidth, barY + barH - 1.2, { align: 'right' }); + yPos += rowH; + }); + yPos += 9; + } + + // --- Application type (Art) as a single 100% stacked bar with legend --- + const artCounts = {}; + applications.forEach((app) => { + const k = (app.art && app.art.trim()) ? app.art.trim() : 'Sonstige'; + artCounts[k] = (artCounts[k] || 0) + 1; + }); + const artKeys = Object.keys(artCounts); + if (totalApplications > 0 && artKeys.length > 0) { + const artPalette = [[37, 99, 235], [13, 148, 136], [217, 119, 6], [219, 39, 119], + [124, 58, 237], [5, 150, 105], [156, 163, 175]]; + ensureSpace(24); + sectionHeading('Bewerbungsart'); + const barY = yPos; + const barH = 7; + let cum = 0; + artKeys.forEach((key, i) => { + const seg = (artCounts[key] / totalApplications) * contentWidth; + const c = artPalette[i % artPalette.length]; + doc.setFillColor(c[0], c[1], c[2]); + doc.rect(margin + cum, barY, seg, barH, 'F'); + cum += seg; + if (i < artKeys.length - 1) { + doc.setFillColor(255, 255, 255); + doc.rect(margin + cum - 0.4, barY, 0.8, barH, 'F'); + } + }); + yPos = barY + barH + 6; + // Legend (wraps across rows when needed) + doc.setFont('helvetica', 'normal'); + doc.setFontSize(8.5); + let lx = margin; + artKeys.forEach((key, i) => { + const c = artPalette[i % artPalette.length]; + const text = `${key} (${artCounts[key]})`; + const itemW = 4.5 + doc.getTextWidth(text) + 7; + if (lx + itemW > margin + contentWidth) { lx = margin; yPos += 5.5; } + doc.setFillColor(c[0], c[1], c[2]); + doc.roundedRect(lx, yPos - 2.6, 3, 3, 0.6, 0.6, 'F'); + doc.setTextColor(55, 65, 81); + doc.text(text, lx + 4.5, yPos); + lx += itemW; + }); + yPos += 8; + } + + // Declarative summary sentence for the official record + ensureSpace(10); + doc.setFont('helvetica', 'italic'); + doc.setFontSize(10.5); + doc.setTextColor(75, 85, 99); + const summaryText = (month || year) + ? `Im Zeitraum ${period} habe ich mich auf ${totalApplications} Stellen beworben.` + : `Insgesamt habe ich mich auf ${totalApplications} Stellen beworben.`; + doc.text(summaryText, pageWidth / 2, yPos, { align: 'center' }); + doc.setTextColor(0, 0, 0); + yPos += 12; + // One block per application — no table, so the note (which documents the // full Verlauf) gets the entire page width and is shown completely. orderedKeys.forEach((statusKey) => { @@ -440,27 +602,28 @@ function generatePdfDocument(settings, applications, month, year) { yPos += 3; } - // Notizen label - doc.setFont('helvetica', 'bold'); - doc.setFontSize(10); - doc.setTextColor(20, 20, 20); - doc.text('Notizen:', margin + 2, yPos + 4); - yPos += 6; + // Notizen — only rendered when the entry actually has notes + if (notizen) { + doc.setFont('helvetica', 'bold'); + doc.setFontSize(10); + doc.setTextColor(20, 20, 20); + doc.text('Notizen:', margin + 2, yPos + 4); + yPos += 6; - // Notizen body — full width, complete, with page breaks line by line - doc.setFont('helvetica', 'normal'); - doc.setFontSize(10); - doc.setTextColor(0, 0, 0); - const lineHeight = 5; - const noteText = notizen || '(keine Notizen)'; - noteText.split(/\r?\n/).forEach((paragraph) => { - const lines = doc.splitTextToSize(paragraph.length ? paragraph : ' ', contentWidth - 4); - lines.forEach((line) => { - ensureSpace(lineHeight); - doc.text(line, margin + 2, yPos + 4); - yPos += lineHeight; + // Notizen body — full width, complete, with page breaks line by line + doc.setFont('helvetica', 'normal'); + doc.setFontSize(10); + doc.setTextColor(0, 0, 0); + const lineHeight = 5; + notizen.split(/\r?\n/).forEach((paragraph) => { + const lines = doc.splitTextToSize(paragraph.length ? paragraph : ' ', contentWidth - 4); + lines.forEach((line) => { + ensureSpace(lineHeight); + doc.text(line, margin + 2, yPos + 4); + yPos += lineHeight; + }); }); - }); + } // Separator before the next entry yPos += 4; @@ -500,7 +663,7 @@ function loadPdfLibraries() { // Check if already loading if (document.getElementById('jspdf-script')) { const checkLoaded = setInterval(() => { - if (typeof jsPDF !== 'undefined') { + if (window.jspdf && window.jspdf.jsPDF) { clearInterval(checkLoaded); pdfLibrariesLoaded = true; resolve(); diff --git a/views/bewerbung.ejs b/views/bewerbung.ejs index 8127482..57e003c 100644 --- a/views/bewerbung.ejs +++ b/views/bewerbung.ejs @@ -3,7 +3,7 @@ <%- include('partials/head') %> - + <%- include('partials/header', { hideSettings: true }) %> <% @@ -191,23 +191,25 @@ +