From 78b1ea78d06262952860bbce14a9c8861de5aae7 Mon Sep 17 00:00:00 2001 From: Thomas Hackner Date: Sun, 5 Jul 2026 14:12:24 +0200 Subject: [PATCH] PDF-Export: kompakte Tabelle, Filter nach letztem Status-Datum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verbose Blöcke (Notizen + kompletter Verlauf) durch eine autoTable ersetzt: eine Zeile pro Bewerbung mit Datum, Firma, Stelle, Art und letztem Status (farbiges Badge). Export filtert/datiert nun nach dem effektiven Datum, also der letzten Statusaenderung – eine im Juni gesendete, im Juli zum Gespraech gewordene Bewerbung erscheint dadurch im Juli-Export. Modal-Auswahl nutzt dieselben effektiven Monate. Co-Authored-By: Claude Opus 4.8 --- public/js/main.js | 188 +++++++++++++++++++--------------------------- server.js | 44 +++++++++-- views/index.ejs | 4 +- 3 files changed, 115 insertions(+), 121 deletions(-) diff --git a/public/js/main.js b/public/js/main.js index e0e3d11..bd63aa8 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -330,10 +330,28 @@ function generatePdfDocument(settings, applications, month, year) { 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; const monthName = month ? monthNames[parseInt(month) - 1] : 'alle'; + + // The status history is sorted ascending, so its last entry is the most recent + // status change. The export keys date + status off that entry (falling back to + // the application's own date/status when there is no history). + const lastVerlauf = (app) => { + const v = Array.isArray(app.verlauf) ? app.verlauf : []; + return v.length ? v[v.length - 1] : null; + }; + const lastStatusOf = (app) => { + const lv = lastVerlauf(app); + const s = (lv && lv.status) || app.status; + return (s && s.trim()) ? s.trim() : 'Ohne Status'; + }; + const effDateOf = (app) => { + const lv = lastVerlauf(app); + return new Date(lv ? lv.datum : app.datum); + }; + // 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)) + applications.map((a) => effDateOf(a).getFullYear()).filter((y) => !isNaN(y)) )].sort((a, b) => a - b); const displayYear = year || (yearsInData.length === 1 ? String(yearsInData[0]) @@ -406,7 +424,7 @@ function generatePdfDocument(settings, applications, month, year) { const groups = {}; applications.forEach((app) => { - const key = (app.status && app.status.trim()) ? app.status.trim() : 'Ohne Status'; + const key = lastStatusOf(app); (groups[key] = groups[key] || []).push(app); }); const orderedKeys = [ @@ -558,120 +576,66 @@ function generatePdfDocument(settings, applications, month, year) { 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) => { - const group = groups[statusKey]; + // ---- Compact application table (one row per application) ---- + // Details such as Notizen and the full Status-Verlauf are intentionally left + // out; every row shows only the last status and the date of that status change. + sectionHeading('Bewerbungen'); - // Status group heading (dark bar), kept together with its first entry - ensureSpace(40); - doc.setFillColor(55, 65, 81); - doc.rect(margin, yPos, contentWidth, 10, 'F'); - doc.setFont('helvetica', 'bold'); - doc.setFontSize(12); - doc.setTextColor(255, 255, 255); - doc.text(`${statusKey} (${group.length})`, margin + 3, yPos + 6.8); - yPos += 14; + const tableRows = applications + .map((app) => ({ app, date: effDateOf(app), status: lastStatusOf(app) })) + .sort((a, b) => a.date - b.date); + + if (tableRows.length === 0) { + doc.setFont('helvetica', 'italic'); + doc.setFontSize(10.5); + doc.setTextColor(107, 114, 128); + doc.text('Keine Bewerbungen im gewählten Zeitraum.', margin, yPos + 2); doc.setTextColor(0, 0, 0); - - group.forEach((app) => { - const datum = new Date(app.datum).toLocaleDateString('de-DE'); - const firma = app.firma || '—'; - const stelle = app.stelle || '—'; - const art = app.art || '—'; - const notizen = (app.notizen || '').trim(); - - // Keep the heading together with the start of its content - ensureSpace(28); - - // Heading bar: Firma – Stelle (left), Datum (right) - doc.setFillColor(225, 232, 240); - doc.rect(margin, yPos, contentWidth, 9, 'F'); - doc.setFont('helvetica', 'bold'); - doc.setFontSize(11); - doc.setTextColor(20, 20, 20); - const heading = doc.splitTextToSize(`${firma} – ${stelle}`, contentWidth - 40)[0]; - doc.text(heading, margin + 2, yPos + 6); - doc.text(datum, pageWidth - margin - 2, yPos + 6, { align: 'right' }); - yPos += 9; - - // Meta line: Art - doc.setFont('helvetica', 'normal'); - doc.setFontSize(9); - doc.setTextColor(90, 90, 90); - doc.text(`Art: ${art}`, margin + 2, yPos + 5); - yPos += 9; - - // Status-Verlauf timeline (chronological status changes with comments) - const verlauf = Array.isArray(app.verlauf) ? app.verlauf : []; - if (verlauf.length) { - doc.setFont('helvetica', 'bold'); - doc.setFontSize(10); - doc.setTextColor(20, 20, 20); - ensureSpace(6); - doc.text('Status-Verlauf:', margin + 2, yPos + 4); - yPos += 6; - - doc.setFontSize(9); - verlauf.forEach((v) => { - const vDatum = new Date(v.datum).toLocaleDateString('de-DE'); - doc.setFont('helvetica', 'bold'); - doc.setTextColor(50, 50, 50); - ensureSpace(5); - doc.text(`${vDatum} — ${v.status || ''}`, margin + 5, yPos + 4); - yPos += 5; - - const kommentar = (v.kommentar || '').trim(); - if (kommentar) { - doc.setFont('helvetica', 'normal'); - doc.setTextColor(0, 0, 0); - kommentar.split(/\r?\n/).forEach((paragraph) => { - const lines = doc.splitTextToSize(paragraph.length ? paragraph : ' ', contentWidth - 12); - lines.forEach((line) => { - ensureSpace(4.5); - doc.text(line, margin + 9, yPos + 3.5); - yPos += 4.5; - }); - }); - } - }); - yPos += 3; + yPos += 10; + } else { + doc.autoTable({ + startY: yPos, + margin: { left: margin, right: margin }, + head: [['Datum', 'Firma', 'Stelle', 'Art', 'Status']], + body: tableRows.map(({ app, date, status }) => ([ + isNaN(date) ? '—' : date.toLocaleDateString('de-DE'), + app.firma || '—', + app.stelle || '—', + app.art || '—', + status + ])), + styles: { + font: 'helvetica', fontSize: 9, cellPadding: 2.4, + textColor: [31, 41, 55], lineColor: [226, 232, 240], + lineWidth: 0.2, valign: 'middle', overflow: 'linebreak' + }, + headStyles: { + fillColor: [55, 65, 81], textColor: [255, 255, 255], + fontStyle: 'bold', fontSize: 9.5, cellPadding: 2.6 + }, + alternateRowStyles: { fillColor: [248, 250, 252] }, + columnStyles: { + 0: { cellWidth: 23 }, + 1: { cellWidth: 41, fontStyle: 'bold' }, + 2: { cellWidth: 'auto' }, + 3: { cellWidth: 25 }, + 4: { cellWidth: 39, halign: 'center', fontStyle: 'bold' } + }, + // Tint the Status cell in its status colour so the column reads like a badge + didParseCell: (data) => { + if (data.section === 'body' && data.column.index === 4) { + const c = colorFor(String(data.cell.raw)); + data.cell.styles.textColor = c; + data.cell.styles.fillColor = [ + Math.round(c[0] * 0.13 + 255 * 0.87), + Math.round(c[1] * 0.13 + 255 * 0.87), + Math.round(c[2] * 0.13 + 255 * 0.87) + ]; + } } - - // 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; - 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; - ensureSpace(6); - doc.setDrawColor(210, 210, 210); - doc.line(margin, yPos, pageWidth - margin, yPos); - yPos += 8; }); - - // Extra spacing after a status group - yPos += 4; - }); + yPos = doc.lastAutoTable.finalY + 10; + } // Footer with confirmation ensureSpace(20); diff --git a/server.js b/server.js index a5ad17b..73824b4 100644 --- a/server.js +++ b/server.js @@ -917,12 +917,29 @@ initializeDatabase().then(() => { // Get available months/years for filter const availableMonths = await dbAll(` - SELECT DISTINCT strftime("%Y-%m", datum) as yearmonth, - strftime("%m", datum) as month, + SELECT DISTINCT strftime("%Y-%m", datum) as yearmonth, + strftime("%m", datum) as month, strftime("%Y", datum) as year FROM bewerbungen ORDER BY datum DESC `); - + + // Months/years for the PDF export, keyed by the effective date (last status + // change) so a period like Juli 2026 is selectable even when the underlying + // application was created in an earlier month. + const exportMonths = await dbAll(` + SELECT DISTINCT strftime("%Y-%m", eff) as yearmonth, + strftime("%m", eff) as month, + strftime("%Y", eff) as year + FROM ( + SELECT COALESCE( + (SELECT MAX(date(sv.datum)) FROM status_verlauf sv WHERE sv.bewerbung_id = b.id), + date(b.datum) + ) AS eff + FROM bewerbungen b + ) + ORDER BY yearmonth DESC + `); + res.render('index', { applications, settings, @@ -932,6 +949,7 @@ initializeDatabase().then(() => { byStatus }, availableMonths, + exportMonths, currentFilter: { month, year }, kommendeTermine, caldavTz: caldav.TZ, @@ -1168,19 +1186,31 @@ initializeDatabase().then(() => { try { const { month, year } = req.query; - let query = 'SELECT * FROM bewerbungen ORDER BY datum DESC'; + // Effektives Datum einer Bewerbung = Datum ihrer letzten Statusänderung + // (fällt auf das Bewerbungsdatum zurück, wenn es keinen Verlauf gibt). Der + // Monatsexport listet eine Bewerbung im Monat ihres LETZTEN Status: Eine im + // Juni gesendete Bewerbung, die im Juli zum Vorstellungsgespräch wird, + // erscheint dadurch im Export für Juli. + const base = ` + SELECT b.*, COALESCE( + (SELECT MAX(date(sv.datum)) FROM status_verlauf sv WHERE sv.bewerbung_id = b.id), + date(b.datum) + ) AS eff_datum + FROM bewerbungen b + `; + let query = `SELECT * FROM (${base}) ORDER BY eff_datum DESC`; const params = []; if (month && year) { - query = 'SELECT * FROM bewerbungen WHERE strftime("%m", datum) = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC'; + query = `SELECT * FROM (${base}) WHERE strftime("%m", eff_datum) = ? AND strftime("%Y", eff_datum) = ? ORDER BY eff_datum DESC`; params.push(month.padStart(2, '0'), year); } else if (month) { // A month without a year must still restrict the export to that month — // never fall through to exporting every application. - query = 'SELECT * FROM bewerbungen WHERE strftime("%m", datum) = ? ORDER BY datum DESC'; + query = `SELECT * FROM (${base}) WHERE strftime("%m", eff_datum) = ? ORDER BY eff_datum DESC`; params.push(month.padStart(2, '0')); } else if (year) { - query = 'SELECT * FROM bewerbungen WHERE strftime("%Y", datum) = ? ORDER BY datum DESC'; + query = `SELECT * FROM (${base}) WHERE strftime("%Y", eff_datum) = ? ORDER BY eff_datum DESC`; params.push(year); } diff --git a/views/index.ejs b/views/index.ejs index 8227d4b..f3bb610 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -732,7 +732,7 @@ name="month" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white"> - <% availableMonths.forEach(m => { %> + <% exportMonths.forEach(m => { %> <% }); %> @@ -747,7 +747,7 @@ name="year" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white"> - <% const pdfYears = [...new Set(availableMonths.map(m => m.year))].sort().reverse(); %> + <% const pdfYears = [...new Set(exportMonths.map(m => m.year))].sort().reverse(); %> <% pdfYears.forEach(y => { %> <% }); %>