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:
+76
-112
@@ -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);
|
||||||
|
|||||||
@@ -923,6 +923,23 @@ initializeDatabase().then(() => {
|
|||||||
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
@@ -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>
|
||||||
<% }); %>
|
<% }); %>
|
||||||
|
|||||||
Reference in New Issue
Block a user