(() => { 'use strict'; const MAX_GOALS = 5; const CALCULATION_DELAY_MS = 3000; const state = { goals: [], nextId: 1 }; const refs = { goalsContainer: document.getElementById('goalsContainer'), addGoalBtn: document.getElementById('addGoalBtn'), resetBtn: document.getElementById('resetBtn'), pdfBtn: document.getElementById('pdfBtn'), previewEmpty: document.getElementById('previewEmpty'), previewContent: document.getElementById('previewContent'), resultsBody: document.getElementById('resultsBody'), resultsTotal: document.getElementById('resultsTotal'), goalTemplate: document.getElementById('goalTemplate'), pdfExportArea: document.getElementById('pdfExportArea') }; let delayedRenderTimer = null; function scheduleCalculation() { clearTimeout(delayedRenderTimer); delayedRenderTimer = window.setTimeout(() => { renderComputedState(); }, CALCULATION_DELAY_MS); } function sanitizeText(value) { return String(value || '') .replace(/[<>]/g, '') .replace(/\s+/g, ' ') .trim() .slice(0, 50); } function parseNumber(value) { if (value === '' || value === null || value === undefined) return null; const prepared = String(value).replace(',', '.'); const parsed = Number(prepared); return Number.isFinite(parsed) ? parsed : null; } function formatMoney(value) { if (!Number.isFinite(value)) return '—'; return `${value.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ₽`; } function getAutoInflation(years) { if (typeof years !== 'number' || !Number.isFinite(years)) return null; if (years <= 5) return 7; if (years >= 5.1) return 8; return 7; } function inflatedTarget(target, inflationPercent, years) { return target * Math.pow(1 + inflationPercent / 100, years); } function calculatePMT(monthlyRate, periods, presentValue, futureValue) { if (!Number.isFinite(periods) || periods <= 0) return NaN; if (!Number.isFinite(monthlyRate) || monthlyRate < 0) return NaN; if (monthlyRate === 0) { return (futureValue - presentValue) / periods; } const factor = Math.pow(1 + monthlyRate, periods); return (monthlyRate * (futureValue - presentValue * factor)) / (factor - 1); } function validateGoal(goal) { const errors = {}; if (!goal.name) { errors.name = 'Введите название цели.'; } if (goal.target === null || goal.target < 1000 || goal.target > 1000000000) { errors.target = 'Введите сумму от 1 000 до 1 000 000 000.'; } if (goal.years === null || goal.years < 0.5 || goal.years > 50) { errors.years = 'Введите срок от 0.5 до 50 лет.'; } if (goal.saved === null || goal.saved < 0) { errors.saved = 'Введите сумму от 0.'; } else if (goal.target !== null && goal.saved > goal.target) { errors.saved = 'Накоплено не может быть больше цели.'; } if (goal.returnPct === null || goal.returnPct < 0 || goal.returnPct > 100) { errors.returnPct = 'Введите доходность от 0 до 100%.'; } if (goal.inflationPct === null || goal.inflationPct < 0 || goal.inflationPct > 50) { errors.inflationPct = 'Введите инфляцию от 0 до 50%.'; } return errors; } function computeGoal(goal) { const errors = validateGoal(goal); const hasErrors = Object.keys(errors).length > 0; if (hasErrors) { return { valid: false, errors, inflatedValue: NaN, monthlyPayment: NaN }; } const targetWithInflation = inflatedTarget(goal.target, goal.inflationPct, goal.years); const monthlyRate = goal.returnPct / 100 / 12; const periods = goal.years * 12; const monthlyPayment = calculatePMT(monthlyRate, periods, -goal.saved, targetWithInflation); if (!Number.isFinite(monthlyPayment) || monthlyPayment < 0) { return { valid: false, errors: { common: 'Исправьте данные для расчета.' }, inflatedValue: targetWithInflation, monthlyPayment: NaN }; } return { valid: true, errors: {}, inflatedValue: targetWithInflation, monthlyPayment }; } function createGoalState(initialValues = {}) { return { id: state.nextId++, name: initialValues.name || '', target: initialValues.target ?? null, years: initialValues.years ?? null, saved: initialValues.saved ?? 0, returnPct: initialValues.returnPct ?? null, inflationPct: initialValues.inflationPct ?? null, elements: null }; } function setFieldError(fieldElement, message) { const input = fieldElement.querySelector('.field__input'); const error = fieldElement.querySelector('.field__error'); if (message) { input.classList.add('field__input--error'); error.textContent = message; } else { input.classList.remove('field__input--error'); error.textContent = ''; } } function bindGoalEvents(goal) { const { elements } = goal; elements.nameInput.addEventListener('input', (event) => { goal.name = sanitizeText(event.target.value); scheduleCalculation(); }); elements.targetInput.addEventListener('input', (event) => { goal.target = parseNumber(event.target.value); scheduleCalculation(); }); elements.yearsInput.addEventListener('input', (event) => { goal.years = parseNumber(event.target.value); const autoInflation = getAutoInflation(goal.years); if (autoInflation !== null) { goal.inflationPct = autoInflation; elements.inflationInput.value = String(autoInflation); } else if (event.target.value === '') { goal.inflationPct = null; elements.inflationInput.value = ''; } scheduleCalculation(); }); elements.savedInput.addEventListener('input', (event) => { goal.saved = parseNumber(event.target.value); scheduleCalculation(); }); elements.returnInput.addEventListener('input', (event) => { goal.returnPct = parseNumber(event.target.value); scheduleCalculation(); }); elements.inflationInput.addEventListener('input', (event) => { goal.inflationPct = parseNumber(event.target.value); scheduleCalculation(); }); elements.removeBtn.addEventListener('click', () => { removeGoal(goal.id); }); } function buildGoalCard(goal, index) { const fragment = refs.goalTemplate.content.cloneNode(true); const card = fragment.querySelector('.goal-card'); const elements = { card, index: fragment.querySelector('.goal-card__index'), removeBtn: fragment.querySelector('.goal-card__remove'), nameField: fragment.querySelector('.js-name').closest('.field'), targetField: fragment.querySelector('.js-target').closest('.field'), yearsField: fragment.querySelector('.js-years').closest('.field'), savedField: fragment.querySelector('.js-saved').closest('.field'), returnField: fragment.querySelector('.js-return').closest('.field'), inflationField: fragment.querySelector('.js-inflation').closest('.field'), nameInput: fragment.querySelector('.js-name'), targetInput: fragment.querySelector('.js-target'), yearsInput: fragment.querySelector('.js-years'), savedInput: fragment.querySelector('.js-saved'), returnInput: fragment.querySelector('.js-return'), inflationInput: fragment.querySelector('.js-inflation'), inflatedValue: fragment.querySelector('.js-inflatedValue'), paymentValue: fragment.querySelector('.js-paymentValue') }; elements.index.textContent = `Цель ${index + 1}`; if (goal.name) elements.nameInput.value = goal.name; if (goal.target !== null) elements.targetInput.value = String(goal.target); if (goal.years !== null) elements.yearsInput.value = String(goal.years); if (goal.saved !== null) elements.savedInput.value = String(goal.saved); if (goal.returnPct !== null) elements.returnInput.value = String(goal.returnPct); if (goal.inflationPct !== null) elements.inflationInput.value = String(goal.inflationPct); goal.elements = elements; bindGoalEvents(goal); return card; } function updateGoalIndexes() { state.goals.forEach((goal, index) => { if (goal.elements?.index) { goal.elements.index.textContent = `Цель ${index + 1}`; } }); } function renderComputedState() { refs.resultsBody.textContent = ''; if (state.goals.length === 0) { refs.previewEmpty.classList.remove('hidden'); refs.previewContent.classList.add('hidden'); refs.resultsTotal.textContent = '0 ₽'; return; } let total = 0; let hasRows = false; state.goals.forEach((goal, index) => { const result = computeGoal(goal); const { elements } = goal; setFieldError(elements.nameField, result.errors.name || ''); setFieldError(elements.targetField, result.errors.target || ''); setFieldError(elements.yearsField, result.errors.years || ''); setFieldError(elements.savedField, result.errors.saved || ''); setFieldError(elements.returnField, result.errors.returnPct || ''); setFieldError(elements.inflationField, result.errors.inflationPct || ''); elements.card.classList.toggle('goal-card--invalid', !result.valid); elements.inflatedValue.textContent = result.valid ? formatMoney(result.inflatedValue) : '—'; elements.paymentValue.textContent = result.valid ? formatMoney(result.monthlyPayment) : '—'; const row = document.createElement('tr'); if (result.valid) { total += result.monthlyPayment; hasRows = true; const nameCell = document.createElement('td'); const targetCell = document.createElement('td'); const inflatedCell = document.createElement('td'); const paymentCell = document.createElement('td'); nameCell.textContent = goal.name || `Цель ${index + 1}`; targetCell.textContent = formatMoney(goal.target); inflatedCell.textContent = formatMoney(result.inflatedValue); paymentCell.textContent = formatMoney(result.monthlyPayment); row.append(nameCell, targetCell, inflatedCell, paymentCell); } else { hasRows = true; row.classList.add('result-row--error'); const nameCell = document.createElement('td'); const errorCell = document.createElement('td'); nameCell.textContent = goal.name || `Цель ${index + 1}`; errorCell.colSpan = 3; errorCell.textContent = 'Исправьте данные для расчета'; row.append(nameCell, errorCell); } refs.resultsBody.appendChild(row); }); refs.previewEmpty.classList.toggle('hidden', hasRows); refs.previewContent.classList.toggle('hidden', !hasRows); refs.resultsTotal.textContent = formatMoney(total); } function addGoal(initialValues = {}) { if (state.goals.length >= MAX_GOALS) return; const goal = createGoalState(initialValues); state.goals.push(goal); const card = buildGoalCard(goal, state.goals.length - 1); refs.goalsContainer.appendChild(card); updateGoalIndexes(); refs.addGoalBtn.disabled = state.goals.length >= MAX_GOALS; renderComputedState(); } function removeGoal(goalId) { const index = state.goals.findIndex((goal) => goal.id === goalId); if (index === -1) return; const [goal] = state.goals.splice(index, 1); if (goal.elements?.card?.parentNode) { goal.elements.card.parentNode.removeChild(goal.elements.card); } updateGoalIndexes(); refs.addGoalBtn.disabled = state.goals.length >= MAX_GOALS; renderComputedState(); } function resetGoals() { state.goals = []; refs.goalsContainer.textContent = ''; refs.addGoalBtn.disabled = false; addGoal({ name: 'Машина', target: 2000000, years: 5, saved: 0, returnPct: 12.5, inflationPct: 7 }); } async function exportPdf() { const html2pdfReady = typeof window.html2pdf !== 'undefined'; const html2canvasReady = typeof window.html2canvas !== 'undefined'; const jsPdfReady = typeof window.jsPDF !== 'undefined' || (typeof window.jspdf !== 'undefined' && typeof window.jspdf.jsPDF !== 'undefined'); if (!jsPdfReady || !html2canvasReady || !html2pdfReady) { console.error('PDF libs state:', { jsPDF: jsPdfReady, html2canvas: html2canvasReady, html2pdf: html2pdfReady, windowJspdf: window.jspdf }); alert('PDF-библиотека не загружена. Проверьте js/jspdf.umd.min.js, js/html2canvas.min.js и js/html2pdf.min.js'); return; } const hasValidGoals = state.goals.some((goal) => computeGoal(goal).valid); if (!hasValidGoals) { alert('Нет валидных результатов для экспорта в PDF.'); return; } const isMobile = /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent); const fileName = `financial-goals-${new Date().toISOString().slice(0, 10)}.pdf`; const options = { margin: [10, 10, 10, 10], filename: fileName, image: { type: 'jpeg', quality: 0.95 }, html2canvas: { scale: isMobile ? 1.5 : 2, useCORS: true }, jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }, pagebreak: { mode: ['avoid-all', 'css', 'legacy'] } }; const originalText = refs.pdfBtn.textContent; refs.pdfBtn.disabled = true; refs.pdfBtn.textContent = 'Формирование PDF...'; try { if (!isMobile) { await window.html2pdf().set(options).from(refs.pdfExportArea).save(); } else { const worker = window.html2pdf().set(options).from(refs.pdfExportArea); const pdfBlob = await worker.outputPdf('blob'); const blobUrl = URL.createObjectURL(pdfBlob); const opened = window.open(blobUrl, '_blank'); if (!opened) { const link = document.createElement('a'); link.href = blobUrl; link.target = '_blank'; link.rel = 'noopener'; link.download = fileName; document.body.appendChild(link); link.click(); document.body.removeChild(link); } setTimeout(() => { URL.revokeObjectURL(blobUrl); }, 60000); } } catch (error) { console.error(error); try { const worker = window.html2pdf().set(options).from(refs.pdfExportArea); const dataUri = await worker.outputPdf('datauristring'); const opened = window.open(dataUri, '_blank'); if (!opened) { alert('Не удалось открыть PDF на мобильном устройстве. Попробуйте на ПК или другой мобильный браузер.'); } } catch (fallbackError) { console.error(fallbackError); alert('Не удалось сформировать PDF. Попробуйте скачать файл с компьютера.'); } } finally { refs.pdfBtn.disabled = false; refs.pdfBtn.textContent = originalText; } } function bindAppEvents() { if (refs.addGoalBtn) { refs.addGoalBtn.addEventListener('click', () => addGoal()); } if (refs.resetBtn) { refs.resetBtn.addEventListener('click', resetGoals); } if (refs.pdfBtn) { refs.pdfBtn.addEventListener('click', () => { exportPdf(); }); } } function init() { if (!refs.goalsContainer || !refs.goalTemplate || !refs.resultsBody || !refs.resultsTotal) { console.error('Ошибка инициализации: не найдены обязательные элементы DOM.'); return; } bindAppEvents(); addGoal({ name: 'Машина', target: 2000000, years: 5, saved: 0, returnPct: 12.5, inflationPct: 7 }); renderComputedState(); } init(); })();