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'];
|
||||
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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>
|
||||
<% }); %>
|
||||
|
||||
Reference in New Issue
Block a user