Files
jobbi-bewerbung/public/js/main.js
T
thomasandClaude 295c447d58 PDF-Export: /api/export monats/jahresbezogen gab 500 (ungueltiges SQL)
Die gefilterten Export-Zweige bauten `SELECT * FROM (<subquery>) AND
strftime(...)` — ohne WHERE nach der abgeleiteten Tabelle. SQLite
quittierte das mit "near \"AND\": syntax error" => HTTP 500, der Client
erhielt {error:'Serverfehler'} statt des Bewerbungs-Arrays und
generatePdfDocument crashte mit "applications.map is not a function".

Fix: `AND` => `WHERE` in den drei gefilterten Zweigen (month+year,
month, year). Die Per-User-Isolation bleibt unberuehrt (user_id-Filter
steht weiterhin in der Subquery). Zusaetzlich prueft generatePDF jetzt
Array.isArray(applications) und zeigt eine klare Fehlermeldung statt
des .map-Crashs.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 02:38:47 +02:00

720 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================
// NextJobs - Client-side JavaScript
// ============================================
// DOM Elements
const body = document.getElementById('body');
// Modal Elements
const applicationModal = document.getElementById('applicationModal');
const deleteModal = document.getElementById('deleteModal');
const pdfExportModal = document.getElementById('pdfExportModal');
// Form Elements
const applicationForm = document.getElementById('applicationForm');
const pdfExportForm = document.getElementById('pdfExportForm');
// Button Elements
const addApplicationBtn = document.getElementById('addApplicationBtn');
const exportPdfBtn = document.getElementById('exportPdfBtn');
// Close Modal Buttons
const closeApplicationModal = document.getElementById('closeApplicationModal');
const closeDeleteModal = document.getElementById('closeDeleteModal');
const closePdfModal = document.getElementById('closePdfModal');
const cancelApplication = document.getElementById('cancelApplication');
const cancelDelete = document.getElementById('cancelDelete');
const cancelPdfExport = document.getElementById('cancelPdfExport');
const confirmDelete = document.getElementById('confirmDelete');
// Global variables
let currentApplicationId = null;
let currentDeleteId = null;
let pdfLibrariesLoaded = false;
// ============================================
// Modal Functions
// ============================================
function showModal(modal) {
modal.classList.remove('hidden');
body.style.overflow = 'hidden';
}
function hideModal(modal) {
modal.classList.add('hidden');
body.style.overflow = '';
}
function resetApplicationForm() {
applicationForm.reset();
document.getElementById('modalTitle').textContent = 'Bewerbung hinzufügen';
currentApplicationId = null;
}
// ============================================
// Application Management (CRUD)
// ============================================
function openAddApplicationModal() {
resetApplicationForm();
// Set default date to today
const today = new Date().toISOString().split('T')[0];
document.getElementById('applicationDatum').value = today;
showModal(applicationModal);
}
function openEditApplicationModal(id) {
// Fetch application data
fetch(`/api/bewerbungen/${id}`)
.then(response => response.json())
.then(application => {
currentApplicationId = application.id;
document.getElementById('modalTitle').textContent = 'Bewerbung bearbeiten';
document.getElementById('applicationId').value = application.id;
document.getElementById('applicationDatum').value = application.datum;
document.getElementById('applicationFirma').value = application.firma;
document.getElementById('applicationStelle').value = application.stelle;
document.getElementById('applicationArt').value = application.art || '';
document.getElementById('applicationStatus').value = application.status || '';
document.getElementById('applicationNotizen').value = application.notizen || '';
showModal(applicationModal);
})
.catch(error => console.error('Error loading application:', error));
}
function saveApplication(event) {
event.preventDefault();
const formData = new FormData(applicationForm);
const application = {
datum: formData.get('datum'),
firma: formData.get('firma'),
stelle: formData.get('stelle'),
art: formData.get('art'),
status: formData.get('status'),
notizen: formData.get('notizen'),
interne_notizen: formData.get('interne_notizen'),
kommentar: formData.get('kommentar'),
labels: formData.getAll('labels')
};
let url = '/api/bewerbungen';
let method = 'POST';
if (currentApplicationId) {
url = `/api/bewerbungen/${currentApplicationId}`;
method = 'PUT';
}
const send = (payload) => fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
send(application)
.then(async (response) => {
// Duplicate guard: the server flags a likely double application (409).
if (response.status === 409) {
const info = await response.json().catch(() => ({}));
if (info.duplicate) {
if (confirm(duplicateWarning(info.matches))) {
return send(Object.assign({}, application, { force: true })).then(r => r.json());
}
return null; // user cancelled — keep the form open
}
}
return response.json();
})
.then(data => {
if (data && data.success) {
hideModal(applicationModal);
resetApplicationForm();
location.reload();
}
})
.catch(error => console.error('Error saving application:', error));
}
// Human-readable warning listing the existing application(s) that look like the
// same job, shown before a possible double application is created.
function duplicateWarning(matches) {
const lines = (matches || []).slice(0, 5).map(m => {
const parts = [m.firma, m.stelle].filter(Boolean).join(' — ');
const meta = [m.datum, m.status].filter(Boolean).join(', ');
return '• ' + parts + (meta ? ' (' + meta + ')' : '');
});
return 'Es gibt bereits eine passende Bewerbung:\n\n' + lines.join('\n') +
'\n\nTrotzdem eine weitere Bewerbung für diese Stelle anlegen?';
}
function openDeleteModal(id) {
currentDeleteId = id;
showModal(deleteModal);
}
function deleteApplication() {
if (!currentDeleteId) return;
fetch(`/api/bewerbungen/${currentDeleteId}`, {
method: 'DELETE'
})
.then(response => response.json())
.then(data => {
if (data.success) {
hideModal(deleteModal);
currentDeleteId = null;
// Refresh the page
location.reload();
}
})
.catch(error => console.error('Error deleting application:', error));
}
// ============================================
// PDF Export
// ============================================
function openPdfExportModal() {
// Load PDF libraries if not already loaded
loadPdfLibraries().then(() => {
showModal(pdfExportModal);
});
}
function generatePDF(event) {
event.preventDefault();
// The month option encodes its year ("YYYY-MM"), so a chosen month always
// exports exactly that month of that year, regardless of the year select.
const monthRaw = document.getElementById('pdfMonth').value;
let year = document.getElementById('pdfYear').value;
let month = '';
if (monthRaw) {
if (monthRaw.indexOf('-') !== -1) {
const parts = monthRaw.split('-');
year = parts[0];
month = parts[1];
} else {
month = monthRaw;
}
}
// Fetch data for PDF
let url = '/api/export?';
const params = [];
if (month) params.push(`month=${month}`);
if (year) params.push(`year=${year}`);
if (params.length > 0) {
url += params.join('&') + '&';
}
// loadPdfLibraries() is awaited here too (not only when the modal opens) so
// the autotable plugin is guaranteed to be attached before we build the doc.
// Without this, opening the modal and submitting before the plugin script
// finished loading would reach generatePdfDocument() with doc.autoTable
// still undefined ("doc.autoTable is not a function").
Promise.all([
loadPdfLibraries(),
fetch('/api/settings').then(res => res.json()),
fetch(url).then(res => res.json())
])
.then(([_loaded, settings, applications]) => {
// /api/export returns an array of applications; on a server error it
// returns { error: '...' } instead. Guard so we surface a real message
// rather than crashing inside generatePdfDocument with "applications.map
// is not a function".
if (!Array.isArray(applications)) {
alert('Export fehlgeschlagen: ' + ((applications && applications.error) || 'Unbekannter Serverfehler'));
return;
}
generatePdfDocument(settings, applications, month, year);
hideModal(pdfExportModal);
})
.catch(error => {
console.error('Error generating PDF:', error);
alert('Export fehlgeschlagen. Bitte erneut versuchen.');
});
}
// PDF Generation with jsPDF
function generatePdfDocument(settings, applications, month, year) {
// The UMD build exposes the constructor as window.jspdf.jsPDF, not as a global jsPDF
const jsPDF = window.jspdf && window.jspdf.jsPDF;
if (typeof jsPDF === 'undefined') {
console.error('jsPDF not loaded, please wait for libraries to load');
alert('Bitte warten Sie einen Moment und versuchen Sie es erneut.');
return;
}
const doc = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4'
});
const monthNames = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'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);
};
// Imported/scraped values can contain HTML entities (e.g. "GmbH &amp; Co. KG")
// and sanitizeInput() stores "< > \" '" escaped — decode them so the PDF shows
// real characters instead of the raw entity text.
const decodeEntities = (str) => {
if (str == null) return '';
return String(str)
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)))
.replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16)))
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&');
};
// Cleaned cell text with an em-dash fallback for missing/empty values.
const cell = (v) => decodeEntities(v).trim() || '—';
// 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) => effDateOf(a).getFullYear()).filter((y) => !isNaN(y))
)].sort((a, b) => a - b);
const displayYear = year
|| (yearsInData.length === 1 ? String(yearsInData[0])
: yearsInData.length > 1 ? `${yearsInData[0]}${yearsInData[yearsInData.length - 1]}` : '');
const period = month
? `${monthName}${displayYear ? ' ' + displayYear : ''}`
: (displayYear || 'alle Zeiträume');
const title = `Bewerbungsaktivitäten - ${period}`;
const dateStr = new Date().toLocaleDateString('de-DE');
// Page geometry
const pageWidth = doc.internal.pageSize.getWidth(); // 210 mm
const pageHeight = doc.internal.pageSize.getHeight(); // 297 mm
const margin = 15;
const contentWidth = pageWidth - margin * 2; // 180 mm
const bottomLimit = pageHeight - margin;
let yPos = 20;
// Start a new page if the next block would not fit
function ensureSpace(needed) {
if (yPos + needed > bottomLimit) {
doc.addPage();
yPos = margin;
}
}
// Header with user data
doc.setFont('helvetica', 'bold');
doc.setFontSize(16);
doc.text(title, pageWidth / 2, yPos, { align: 'center' });
yPos += 12;
// User information. Values like the address can span multiple lines, so we
// split them and advance yPos by the actual number of rendered lines
// otherwise the next field overlaps the wrapped text.
doc.setFontSize(12);
doc.setFont('helvetica', 'normal');
const lineHeight = 7;
function drawField(label, value) {
if (!value) return;
const lines = doc.splitTextToSize(`${label}: ${value}`, contentWidth);
doc.text(lines, margin, yPos);
yPos += lineHeight * lines.length;
}
if (settings) {
drawField('Name', settings.name);
drawField('Adresse', settings.adresse);
drawField('Kundennummer', settings.kundennummer);
}
yPos += 6;
// ---- Group entries by status (order kept in sync with views/index.ejs) ----
const statusOrder = ['Entwurf', 'Gesendet', 'Eingangsbestätigung',
'In Bearbeitung', 'Interessiert', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
'Vorstellungsgespräch', 'Einstellung', 'Absage', 'Absage von meiner Seite', 'Keine Rückmeldung'];
// Accent colour per status (RGB) reused by the chart and the list headings
const statusColors = {
'Entwurf': [168, 85, 247],
'Gesendet': [59, 130, 246],
'Eingangsbestätigung': [14, 165, 233],
'In Bearbeitung': [20, 184, 166],
'Interessiert': [236, 72, 153],
'Warten auf Rückmeldung': [249, 115, 22],
'Warten auf meine Antwort': [217, 70, 239],
'Vorstellungsgespräch': [245, 158, 11],
'Einstellung': [34, 197, 94],
'Absage': [239, 68, 68],
'Absage von meiner Seite': [225, 29, 72],
'Keine Rückmeldung': [107, 114, 128],
'Ohne Status': [148, 163, 184]
};
const fallbackColor = [99, 102, 241];
const colorFor = (s) => statusColors[s] || fallbackColor;
const groups = {};
applications.forEach((app) => {
const key = lastStatusOf(app);
(groups[key] = groups[key] || []).push(app);
});
const orderedKeys = [
...statusOrder.filter((s) => groups[s]),
...Object.keys(groups).filter((k) => !statusOrder.includes(k))
];
const totalApplications = applications.length;
const countOf = (s) => (groups[s] ? groups[s].length : 0);
const pct = (n) => (totalApplications ? Math.round((n / totalApplications) * 100) : 0);
// ============================================================
// Statistics overview: KPI cards + distribution charts
// ============================================================
function sectionHeading(text) {
ensureSpace(12);
doc.setFont('helvetica', 'bold');
doc.setFontSize(12);
doc.setTextColor(31, 41, 55);
doc.text(text, margin, yPos);
yPos += 2.5;
doc.setDrawColor(209, 213, 219);
doc.setLineWidth(0.4);
doc.line(margin, yPos, margin + contentWidth, yPos);
yPos += 6;
}
sectionHeading('Übersicht');
// --- KPI cards ---
const answered = totalApplications - countOf('Gesendet')
- countOf('Keine Rückmeldung') - countOf('Ohne Status');
const kpis = [
{ label: 'Bewerbungen', value: String(totalApplications), color: [55, 65, 81] },
{ label: 'Gespräche', value: String(countOf('Vorstellungsgespräch')), color: statusColors['Vorstellungsgespräch'] },
{ label: 'Einstellungen', value: String(countOf('Einstellung')), color: statusColors['Einstellung'] },
{ label: 'Absagen', value: String(countOf('Absage')), color: statusColors['Absage'] },
{ label: 'Antwortquote', value: pct(answered) + '%', color: [59, 130, 246] }
];
const cardGap = 3.5;
const cardW = (contentWidth - cardGap * (kpis.length - 1)) / kpis.length;
const cardH = 22;
ensureSpace(cardH + 4);
const cardTop = yPos;
kpis.forEach((kpi, i) => {
const x = margin + i * (cardW + cardGap);
doc.setFillColor(248, 250, 252);
doc.roundedRect(x, cardTop, cardW, cardH, 2, 2, 'F');
doc.setFont('helvetica', 'bold');
doc.setFontSize(19);
doc.setTextColor(kpi.color[0], kpi.color[1], kpi.color[2]);
doc.text(kpi.value, x + cardW / 2, cardTop + 11, { align: 'center' });
doc.setFillColor(kpi.color[0], kpi.color[1], kpi.color[2]);
doc.roundedRect(x + cardW / 2 - 5, cardTop + 13.5, 10, 1.1, 0.5, 0.5, 'F');
doc.setFont('helvetica', 'normal');
doc.setFontSize(8);
doc.setTextColor(107, 114, 128);
doc.text(kpi.label, x + cardW / 2, cardTop + 18.5, { align: 'center' });
});
yPos = cardTop + cardH + 10;
// --- Status distribution as a horizontal bar chart ---
if (totalApplications > 0) {
sectionHeading('Status-Verteilung');
const maxCount = Math.max(...orderedKeys.map((k) => groups[k].length));
const labelW = 46;
const valueW = 24;
const trackX = margin + labelW;
const trackW = contentWidth - labelW - valueW;
const rowH = 8;
orderedKeys.forEach((key) => {
const count = groups[key].length;
ensureSpace(rowH);
const barY = yPos + 1.4;
const barH = 5;
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.setTextColor(55, 65, 81);
doc.text(doc.splitTextToSize(key, labelW - 3)[0], margin, barY + barH - 1.2);
doc.setFillColor(237, 240, 244);
doc.roundedRect(trackX, barY, trackW, barH, 1, 1, 'F');
const c = colorFor(key);
const w = maxCount ? Math.max(1.5, (count / maxCount) * trackW) : 1.5;
doc.setFillColor(c[0], c[1], c[2]);
doc.roundedRect(trackX, barY, w, barH, 1, 1, 'F');
doc.setFont('helvetica', 'bold');
doc.setFontSize(8.5);
doc.setTextColor(55, 65, 81);
doc.text(`${count} (${pct(count)}%)`, margin + contentWidth, barY + barH - 1.2, { align: 'right' });
yPos += rowH;
});
yPos += 9;
}
// --- Application type (Art) as a single 100% stacked bar with legend ---
const artCounts = {};
applications.forEach((app) => {
const k = (app.art && app.art.trim()) ? app.art.trim() : 'Sonstige';
artCounts[k] = (artCounts[k] || 0) + 1;
});
const artKeys = Object.keys(artCounts);
if (totalApplications > 0 && artKeys.length > 0) {
const artPalette = [[37, 99, 235], [13, 148, 136], [217, 119, 6], [219, 39, 119],
[124, 58, 237], [5, 150, 105], [156, 163, 175]];
ensureSpace(24);
sectionHeading('Bewerbungsart');
const barY = yPos;
const barH = 7;
let cum = 0;
artKeys.forEach((key, i) => {
const seg = (artCounts[key] / totalApplications) * contentWidth;
const c = artPalette[i % artPalette.length];
doc.setFillColor(c[0], c[1], c[2]);
doc.rect(margin + cum, barY, seg, barH, 'F');
cum += seg;
if (i < artKeys.length - 1) {
doc.setFillColor(255, 255, 255);
doc.rect(margin + cum - 0.4, barY, 0.8, barH, 'F');
}
});
yPos = barY + barH + 6;
// Legend (wraps across rows when needed)
doc.setFont('helvetica', 'normal');
doc.setFontSize(8.5);
let lx = margin;
artKeys.forEach((key, i) => {
const c = artPalette[i % artPalette.length];
const text = `${key} (${artCounts[key]})`;
const itemW = 4.5 + doc.getTextWidth(text) + 7;
if (lx + itemW > margin + contentWidth) { lx = margin; yPos += 5.5; }
doc.setFillColor(c[0], c[1], c[2]);
doc.roundedRect(lx, yPos - 2.6, 3, 3, 0.6, 0.6, 'F');
doc.setTextColor(55, 65, 81);
doc.text(text, lx + 4.5, yPos);
lx += itemW;
});
yPos += 8;
}
// Declarative summary sentence for the official record
ensureSpace(10);
doc.setFont('helvetica', 'italic');
doc.setFontSize(10.5);
doc.setTextColor(75, 85, 99);
const summaryText = (month || year)
? `Im Zeitraum ${period} habe ich mich auf ${totalApplications} Stellen beworben.`
: `Insgesamt habe ich mich auf ${totalApplications} Stellen beworben.`;
doc.text(summaryText, pageWidth / 2, yPos, { align: 'center' });
doc.setTextColor(0, 0, 0);
yPos += 12;
// ---- 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');
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);
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'),
cell(app.firma),
cell(app.stelle),
cell(app.art),
status
])),
// Never split a row across a page boundary — a split row leaves the
// single-line cells (Datum, Art, Status) blank on the continuation page.
rowPageBreak: 'avoid',
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)
];
}
}
});
yPos = doc.lastAutoTable.finalY + 10;
}
// Footer with confirmation
ensureSpace(20);
yPos += 6;
doc.setFont('helvetica', 'italic');
doc.setFontSize(10);
doc.setTextColor(0, 0, 0);
doc.text('Ich versichere, dass die oben genannten Angaben der Wahrheit entsprechen.', pageWidth / 2, yPos, { align: 'center' });
yPos += 7;
doc.text(`Datum: ${dateStr}`, pageWidth / 2, yPos, { align: 'center' });
// Save the PDF
const fileName = `Bewerbungsaktivitaeten_${monthName}_${year || 'alle'}.pdf`;
doc.save(fileName);
}
// Load PDF libraries dynamically
function loadPdfLibraries() {
return new Promise((resolve) => {
if (pdfLibrariesLoaded) {
resolve();
return;
}
// Check if already loading
if (document.getElementById('jspdf-script')) {
const checkLoaded = setInterval(() => {
// Wait for the autotable plugin too, not just jsPDF: jsPDF loads
// first (script1), the plugin second (script2). Resolving the
// moment jsPDF exists leaves doc.autoTable undefined if script2
// has not run yet.
if (window.jspdf && window.jspdf.jsPDF
&& typeof window.jspdf.jsPDF.API.autoTable === 'function') {
clearInterval(checkLoaded);
pdfLibrariesLoaded = true;
resolve();
}
}, 100);
return;
}
// Load jsPDF from the app's own /vendor (self-hosted) so no external
// script source has to be trusted in the Content-Security-Policy.
const script1 = document.createElement('script');
script1.id = 'jspdf-script';
script1.src = '/vendor/jspdf.umd.min.js';
script1.onload = () => {
const script2 = document.createElement('script');
script2.src = '/vendor/jspdf.plugin.autotable.min.js';
script2.onload = () => {
pdfLibrariesLoaded = true;
resolve();
};
document.head.appendChild(script2);
};
document.head.appendChild(script1);
});
}
// ============================================
// Event Listeners
// ============================================
// Application Modal
addApplicationBtn.addEventListener('click', openAddApplicationModal);
closeApplicationModal.addEventListener('click', () => {
hideModal(applicationModal);
resetApplicationForm();
});
cancelApplication.addEventListener('click', () => {
hideModal(applicationModal);
resetApplicationForm();
});
applicationForm.addEventListener('submit', saveApplication);
// Delete Modal
closeDeleteModal.addEventListener('click', () => hideModal(deleteModal));
cancelDelete.addEventListener('click', () => hideModal(deleteModal));
confirmDelete.addEventListener('click', deleteApplication);
// PDF Export Modal
exportPdfBtn.addEventListener('click', openPdfExportModal);
closePdfModal.addEventListener('click', () => hideModal(pdfExportModal));
cancelPdfExport.addEventListener('click', () => hideModal(pdfExportModal));
pdfExportForm.addEventListener('submit', generatePDF);
// Delete buttons (event delegation). Editing opens its own page (/bewerbung/:id).
document.addEventListener('click', (e) => {
if (e.target.closest('.delete-btn')) {
const id = e.target.closest('.delete-btn').dataset.id;
openDeleteModal(id);
}
});
// Close modals when clicking outside
document.addEventListener('click', (e) => {
if (e.target === applicationModal) {
hideModal(applicationModal);
resetApplicationForm();
}
if (e.target === deleteModal) hideModal(deleteModal);
if (e.target === pdfExportModal) hideModal(pdfExportModal);
});
// Close modals with Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (!applicationModal.classList.contains('hidden')) {
hideModal(applicationModal);
resetApplicationForm();
}
if (!deleteModal.classList.contains('hidden')) hideModal(deleteModal);
if (!pdfExportModal.classList.contains('hidden')) hideModal(pdfExportModal);
}
});
// Set current year in footer
document.getElementById('currentYear').textContent = new Date().getFullYear();
// ============================================
// Initialize
// ============================================
console.log('NextJobs initialized');