// ============================================ // 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('&') + '&'; } 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 & 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(/"/g, '"') .replace(/'/g, "'") .replace(/</g, '<') .replace(/>/g, '>') .replace(/ /g, ' ') .replace(/&/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 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');