Files
jobbi-bewerbung/public/js/main.js
T
thomasandClaude Opus 4.8 78b1ea78d0 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>
2026-07-05 14:12:24 +02:00

788 lines
29 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');
const darkModeToggle = document.getElementById('darkModeToggle');
const sunIcon = document.getElementById('sunIcon');
const moonIcon = document.getElementById('moonIcon');
// Modal Elements
const settingsModal = document.getElementById('settingsModal');
const applicationModal = document.getElementById('applicationModal');
const deleteModal = document.getElementById('deleteModal');
const pdfExportModal = document.getElementById('pdfExportModal');
// Form Elements
const settingsForm = document.getElementById('settingsForm');
const applicationForm = document.getElementById('applicationForm');
const pdfExportForm = document.getElementById('pdfExportForm');
const filterForm = document.getElementById('filterForm');
// Button Elements
const settingsBtn = document.getElementById('settingsBtn');
const addApplicationBtn = document.getElementById('addApplicationBtn');
const exportPdfBtn = document.getElementById('exportPdfBtn');
// Close Modal Buttons
const closeSettingsModal = document.getElementById('closeSettingsModal');
const closeApplicationModal = document.getElementById('closeApplicationModal');
const closeDeleteModal = document.getElementById('closeDeleteModal');
const closePdfModal = document.getElementById('closePdfModal');
const cancelSettings = document.getElementById('cancelSettings');
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;
// ============================================
// Dark Mode
// ============================================
function initializeDarkMode() {
// Check localStorage for dark mode preference
const darkMode = localStorage.getItem('darkMode');
// The `dark` class must live on <html> so that Tailwind's `.dark .dark:*`
// selectors apply to the <body> itself, not just its descendants.
if (darkMode === 'enabled' || (!darkMode && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
sunIcon.classList.add('hidden');
moonIcon.classList.remove('hidden');
} else {
document.documentElement.classList.remove('dark');
sunIcon.classList.remove('hidden');
moonIcon.classList.add('hidden');
}
}
function toggleDarkMode() {
document.documentElement.classList.toggle('dark');
sunIcon.classList.toggle('hidden');
moonIcon.classList.toggle('hidden');
// Save preference to localStorage
if (document.documentElement.classList.contains('dark')) {
localStorage.setItem('darkMode', 'enabled');
} else {
localStorage.setItem('darkMode', 'disabled');
}
}
// ============================================
// 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;
}
// ============================================
// Settings Management
// ============================================
function loadSettings() {
fetch('/api/settings')
.then(response => response.json())
.then(settings => {
if (settings) {
document.getElementById('userName').value = settings.name || '';
document.getElementById('userAddress').value = settings.adresse || '';
document.getElementById('customerNumber').value = settings.kundennummer || '';
}
})
.catch(error => console.error('Error loading settings:', error));
}
function saveSettings(event) {
event.preventDefault();
const formData = new FormData(settingsForm);
const settings = {
name: formData.get('name'),
adresse: formData.get('adresse'),
kundennummer: formData.get('kundennummer')
};
fetch('/api/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(settings)
})
.then(response => response.json())
.then(data => {
if (data.success) {
hideModal(settingsModal);
// Refresh page to update settings in header
location.reload();
}
})
.catch(error => console.error('Error saving settings:', error));
}
// ============================================
// 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')
};
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);
};
// 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', 'Vorstellungsgespräch',
'Einstellung', 'Absage', '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],
'Vorstellungsgespräch': [245, 158, 11],
'Einstellung': [34, 197, 94],
'Absage': [239, 68, 68],
'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'),
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)
];
}
}
});
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
// ============================================
// Dark Mode Toggle
darkModeToggle.addEventListener('click', toggleDarkMode);
// Settings Modal
settingsBtn.addEventListener('click', () => {
loadSettings();
showModal(settingsModal);
});
closeSettingsModal.addEventListener('click', () => hideModal(settingsModal));
cancelSettings.addEventListener('click', () => hideModal(settingsModal));
settingsForm.addEventListener('submit', saveSettings);
// 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);
// Filter Form
document.getElementById('filterForm').addEventListener('submit', function(e) {
e.preventDefault();
const month = document.getElementById('filterMonth').value;
const year = document.getElementById('filterYear').value;
let url = '/';
if (month || year) {
url += '?';
if (month) url += `month=${month}&`;
if (year) url += `year=${year}&`;
}
window.location.href = url;
});
// 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 === settingsModal) hideModal(settingsModal);
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 (!settingsModal.classList.contains('hidden')) hideModal(settingsModal);
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
// ============================================
// Initialize dark mode
initializeDarkMode();
console.log('Bewerbungs-Tracker initialized');