Vier mittlere Pipeline-Status ergänzt (nach Eingangsbestätigung, vor Vorstellungsgespräch), inkl. Farben/Reihenfolge in Übersicht, Detailseite und PDF-Export sowie REST-API-Validierung und Swagger-Enum. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
815 lines
30 KiB
JavaScript
815 lines
30 KiB
JavaScript
// ============================================
|
||
// 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'),
|
||
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', '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],
|
||
'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
|
||
// ============================================
|
||
|
||
// 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');
|