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']; 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
const monthName = month ? monthNames[parseInt(month) - 1] : 'alle'; 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, // 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". // derive it from the data so the title still reads e.g. "Juni 2026".
const yearsInData = [...new Set( 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); )].sort((a, b) => a - b);
const displayYear = year const displayYear = year
|| (yearsInData.length === 1 ? String(yearsInData[0]) || (yearsInData.length === 1 ? String(yearsInData[0])
@@ -406,7 +424,7 @@ function generatePdfDocument(settings, applications, month, year) {
const groups = {}; const groups = {};
applications.forEach((app) => { 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); (groups[key] = groups[key] || []).push(app);
}); });
const orderedKeys = [ const orderedKeys = [
@@ -558,120 +576,66 @@ function generatePdfDocument(settings, applications, month, year) {
doc.setTextColor(0, 0, 0); doc.setTextColor(0, 0, 0);
yPos += 12; yPos += 12;
// One block per application — no table, so the note (which documents the // ---- Compact application table (one row per application) ----
// full Verlauf) gets the entire page width and is shown completely. // Details such as Notizen and the full Status-Verlauf are intentionally left
orderedKeys.forEach((statusKey) => { // out; every row shows only the last status and the date of that status change.
const group = groups[statusKey]; sectionHeading('Bewerbungen');
// Status group heading (dark bar), kept together with its first entry const tableRows = applications
ensureSpace(40); .map((app) => ({ app, date: effDateOf(app), status: lastStatusOf(app) }))
doc.setFillColor(55, 65, 81); .sort((a, b) => a.date - b.date);
doc.rect(margin, yPos, contentWidth, 10, 'F');
doc.setFont('helvetica', 'bold'); if (tableRows.length === 0) {
doc.setFontSize(12); doc.setFont('helvetica', 'italic');
doc.setTextColor(255, 255, 255); doc.setFontSize(10.5);
doc.text(`${statusKey} (${group.length})`, margin + 3, yPos + 6.8); doc.setTextColor(107, 114, 128);
yPos += 14; doc.text('Keine Bewerbungen im gewählten Zeitraum.', margin, yPos + 2);
doc.setTextColor(0, 0, 0); doc.setTextColor(0, 0, 0);
yPos += 10;
group.forEach((app) => { } else {
const datum = new Date(app.datum).toLocaleDateString('de-DE'); doc.autoTable({
const firma = app.firma || '—'; startY: yPos,
const stelle = app.stelle || '—'; margin: { left: margin, right: margin },
const art = app.art || '—'; head: [['Datum', 'Firma', 'Stelle', 'Art', 'Status']],
const notizen = (app.notizen || '').trim(); body: tableRows.map(({ app, date, status }) => ([
isNaN(date) ? '—' : date.toLocaleDateString('de-DE'),
// Keep the heading together with the start of its content app.firma || '—',
ensureSpace(28); app.stelle || '—',
app.art || '—',
// Heading bar: Firma Stelle (left), Datum (right) status
doc.setFillColor(225, 232, 240); ])),
doc.rect(margin, yPos, contentWidth, 9, 'F'); styles: {
doc.setFont('helvetica', 'bold'); font: 'helvetica', fontSize: 9, cellPadding: 2.4,
doc.setFontSize(11); textColor: [31, 41, 55], lineColor: [226, 232, 240],
doc.setTextColor(20, 20, 20); lineWidth: 0.2, valign: 'middle', overflow: 'linebreak'
const heading = doc.splitTextToSize(`${firma} ${stelle}`, contentWidth - 40)[0]; },
doc.text(heading, margin + 2, yPos + 6); headStyles: {
doc.text(datum, pageWidth - margin - 2, yPos + 6, { align: 'right' }); fillColor: [55, 65, 81], textColor: [255, 255, 255],
yPos += 9; fontStyle: 'bold', fontSize: 9.5, cellPadding: 2.6
},
// Meta line: Art alternateRowStyles: { fillColor: [248, 250, 252] },
doc.setFont('helvetica', 'normal'); columnStyles: {
doc.setFontSize(9); 0: { cellWidth: 23 },
doc.setTextColor(90, 90, 90); 1: { cellWidth: 41, fontStyle: 'bold' },
doc.text(`Art: ${art}`, margin + 2, yPos + 5); 2: { cellWidth: 'auto' },
yPos += 9; 3: { cellWidth: 25 },
4: { cellWidth: 39, halign: 'center', fontStyle: 'bold' }
// Status-Verlauf timeline (chronological status changes with comments) },
const verlauf = Array.isArray(app.verlauf) ? app.verlauf : []; // Tint the Status cell in its status colour so the column reads like a badge
if (verlauf.length) { didParseCell: (data) => {
doc.setFont('helvetica', 'bold'); if (data.section === 'body' && data.column.index === 4) {
doc.setFontSize(10); const c = colorFor(String(data.cell.raw));
doc.setTextColor(20, 20, 20); data.cell.styles.textColor = c;
ensureSpace(6); data.cell.styles.fillColor = [
doc.text('Status-Verlauf:', margin + 2, yPos + 4); Math.round(c[0] * 0.13 + 255 * 0.87),
yPos += 6; Math.round(c[1] * 0.13 + 255 * 0.87),
Math.round(c[2] * 0.13 + 255 * 0.87)
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;
} }
// 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;
}); });
yPos = doc.lastAutoTable.finalY + 10;
// Extra spacing after a status group }
yPos += 4;
});
// Footer with confirmation // Footer with confirmation
ensureSpace(20); ensureSpace(20);
+37 -7
View File
@@ -917,12 +917,29 @@ initializeDatabase().then(() => {
// Get available months/years for filter // Get available months/years for filter
const availableMonths = await dbAll(` const availableMonths = await dbAll(`
SELECT DISTINCT strftime("%Y-%m", datum) as yearmonth, SELECT DISTINCT strftime("%Y-%m", datum) as yearmonth,
strftime("%m", datum) as month, strftime("%m", datum) as month,
strftime("%Y", datum) as year strftime("%Y", datum) as year
FROM bewerbungen ORDER BY datum DESC 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', { res.render('index', {
applications, applications,
settings, settings,
@@ -932,6 +949,7 @@ initializeDatabase().then(() => {
byStatus byStatus
}, },
availableMonths, availableMonths,
exportMonths,
currentFilter: { month, year }, currentFilter: { month, year },
kommendeTermine, kommendeTermine,
caldavTz: caldav.TZ, caldavTz: caldav.TZ,
@@ -1168,19 +1186,31 @@ initializeDatabase().then(() => {
try { try {
const { month, year } = req.query; 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 = []; const params = [];
if (month && year) { 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); params.push(month.padStart(2, '0'), year);
} else if (month) { } else if (month) {
// A month without a year must still restrict the export to that month — // A month without a year must still restrict the export to that month —
// never fall through to exporting every application. // 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')); params.push(month.padStart(2, '0'));
} else if (year) { } 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); params.push(year);
} }
+2 -2
View File
@@ -732,7 +732,7 @@
name="month" 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"> 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> <option value="">-- Alle Monate --</option>
<% availableMonths.forEach(m => { %> <% exportMonths.forEach(m => { %>
<option value="<%= m.year %>-<%= m.month %>"><%= m.month %> - <%= m.year %></option> <option value="<%= m.year %>-<%= m.month %>"><%= m.month %> - <%= m.year %></option>
<% }); %> <% }); %>
</select> </select>
@@ -747,7 +747,7 @@
name="year" 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"> 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> <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 => { %> <% pdfYears.forEach(y => { %>
<option value="<%= y %>"><%= y %></option> <option value="<%= y %>"><%= y %></option>
<% }); %> <% }); %>