PDF-Export: kompakte Tabelle, Filter nach letztem Status-Datum

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 14:12:24 +02:00
co-authored by Claude Opus 4.8
parent b269dcaecc
commit 78b1ea78d0
3 changed files with 115 additions and 121 deletions
+76 -112
View File
@@ -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);
+34 -4
View File
@@ -923,6 +923,23 @@ initializeDatabase().then(() => {
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);
}
+2 -2
View File
@@ -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">
<option value="">-- Alle Monate --</option>
<% availableMonths.forEach(m => { %>
<% exportMonths.forEach(m => { %>
<option value="<%= m.year %>-<%= m.month %>"><%= m.month %> - <%= m.year %></option>
<% }); %>
</select>
@@ -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">
<option value="">-- Alle Jahre --</option>
<% 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 => { %>
<option value="<%= y %>"><%= y %></option>
<% }); %>