Files
jobbi-bewerbung/public/js/main.js
T
thomasandClaude 9f000e2719 Filterbereich entfernt, Suche + Aktionen als Toolbar zusammengefasst
- Monat/Jahr-Filter geloescht (Server-Filter bleibt via URL nutzbar)
- Suchfeld und die Buttons 'PDF Export' und 'Bewerbung hinzufuegen'
  in einer gemeinsamen Toolbar zusammengefasst, als Pill-Buttons
- Tote filterForm-CSS und -JS entfernt

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

697 lines
26 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.
// ============================================
// Bewerbungs-Tracker - 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('&') + '&';
}
Promise.all([
fetch('/api/settings').then(res => res.json()),
fetch(url).then(res => res.json())
])
.then(([settings, applications]) => {
generatePdfDocument(settings, applications, month, year);
hideModal(pdfExportModal);
})
.catch(error => console.error('Error generating PDF:', error));
}
// 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(() => {
if (window.jspdf && window.jspdf.jsPDF) {
clearInterval(checkLoaded);
pdfLibrariesLoaded = true;
resolve();
}
}, 100);
return;
}
// Load jsPDF from CDN
const script1 = document.createElement('script');
script1.id = 'jspdf-script';
script1.src = 'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js';
script1.onload = () => {
const script2 = document.createElement('script');
script2.src = 'https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/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('Bewerbungs-Tracker initialized');