diff --git a/app/main.py b/app/main.py index 2f6f28c..c5d112e 100644 --- a/app/main.py +++ b/app/main.py @@ -302,22 +302,105 @@ def _bar_chart(yearly: dict[int, float], title: str, width=480, height=165) -> D def _pie_chart(parts: list[tuple[str, float, str]], title: str, width=240, height=165) -> Drawing: drawing = Drawing(width, height) drawing.add(String(0, height - 12, title, fontName=APP_FONT_BOLD, fontSize=9, fillColor=colors.HexColor("#111827"))) - total = sum(v for _, v, _ in parts) or 1 + positive_parts = [(name, max(float(value or 0), 0.0), color) for name, value, color in parts if float(value or 0) > 0] + total = sum(v for _, v, _ in positive_parts) or 1 cx, cy, r = 65, 78, 45 start = 90 - for name, value, color in parts: + for idx, (name, value, color) in enumerate(positive_parts): extent = 360 * value / total + if idx == len(positive_parts) - 1: + extent = min(extent, 359.99) + if extent <= 0: + continue drawing.add(Wedge(cx, cy, r, start, start + extent, fillColor=colors.HexColor(color), strokeColor=colors.white, strokeWidth=0.5)) start += extent ly = height - 38 for name, value, color in parts: drawing.add(Rect(130, ly - 2, 7, 7, fillColor=colors.HexColor(color), strokeColor=None)) - pct = value / total * 100 + pct = max(float(value or 0), 0.0) / total * 100 drawing.add(String(142, ly, f"{name}: {pct:.1f}%", fontName=APP_FONT, fontSize=7, fillColor=colors.HexColor("#111827"))) ly -= 14 return drawing +def _sum_rows(rows, getter) -> float: + return sum(float(getter(row) or 0) for row in rows) + + +def _comparison_line_chart(actual_rows, baseline_rows, value_attr: str, title: str, actual_label: str, baseline_label: str, width=480, height=175) -> Drawing: + drawing = Drawing(width, height) + margin_l, margin_r, margin_b, margin_t = 42, 12, 30, 28 + plot_w = width - margin_l - margin_r + plot_h = height - margin_t - margin_b + x0, y0 = margin_l, margin_b + count = max(len(actual_rows), len(baseline_rows), 1) + actual = [float(getattr(r, value_attr)) for r in actual_rows] + baseline = [float(getattr(r, value_attr)) for r in baseline_rows] + max_v = max(actual + baseline + [1]) or 1 + + drawing.add(String(0, height - 12, title, fontName=APP_FONT_BOLD, fontSize=9, fillColor=colors.HexColor("#111827"))) + drawing.add(Line(x0, y0, x0, y0 + plot_h, strokeColor=colors.HexColor("#9ca3af"), strokeWidth=0.7)) + drawing.add(Line(x0, y0, x0 + plot_w, y0, strokeColor=colors.HexColor("#9ca3af"), strokeWidth=0.7)) + for i in range(1, 5): + gy = y0 + plot_h * i / 4 + drawing.add(Line(x0, gy, x0 + plot_w, gy, strokeColor=colors.HexColor("#e5e7eb"), strokeWidth=0.4)) + label = _money(max_v * i / 4).replace(" PLN", "") + drawing.add(String(2, gy - 3, label, fontName=APP_FONT, fontSize=6, fillColor=colors.HexColor("#6b7280"))) + + def add_series(values, color): + if not values: + return + step = max(1, math.ceil(len(values) / 420)) + sampled = values[::step] + if values[-1] != sampled[-1]: + sampled.append(values[-1]) + sampled_count = max(len(sampled) - 1, 1) + points = [] + for idx, value in enumerate(sampled): + x = x0 + plot_w * idx / sampled_count + y = y0 + plot_h * (value / max_v) + points.extend([x, y]) + drawing.add(PolyLine(points, strokeColor=colors.HexColor(color), strokeWidth=1.4)) + + add_series(baseline, "#64748b") + add_series(actual, "#2563eb") + drawing.add(Rect(x0, 8, 7, 7, fillColor=colors.HexColor("#2563eb"), strokeColor=None)) + drawing.add(String(x0 + 10, 8, actual_label, fontName=APP_FONT, fontSize=6.5, fillColor=colors.HexColor("#111827"))) + drawing.add(Rect(x0 + 150, 8, 7, 7, fillColor=colors.HexColor("#64748b"), strokeColor=None)) + drawing.add(String(x0 + 160, 8, baseline_label, fontName=APP_FONT, fontSize=6.5, fillColor=colors.HexColor("#111827"))) + drawing.add(String(x0 + plot_w - 28, 8, f"{count} mies.", fontName=APP_FONT, fontSize=6, fillColor=colors.HexColor("#6b7280"))) + return drawing + + +def _comparison_bar_chart(items: list[tuple[str, float, float]], title: str, actual_label: str, baseline_label: str, width=480, height=165) -> Drawing: + drawing = Drawing(width, height) + margin_l, margin_r, margin_b, margin_t = 70, 12, 34, 26 + plot_w = width - margin_l - margin_r + plot_h = height - margin_t - margin_b + x0, y0 = margin_l, margin_b + max_v = max([v for _, a, b in items for v in (a, b)] + [1]) + drawing.add(String(0, height - 12, title, fontName=APP_FONT_BOLD, fontSize=9, fillColor=colors.HexColor("#111827"))) + drawing.add(Line(x0, y0, x0, y0 + plot_h, strokeColor=colors.HexColor("#9ca3af"), strokeWidth=0.7)) + drawing.add(Line(x0, y0, x0 + plot_w, y0, strokeColor=colors.HexColor("#9ca3af"), strokeWidth=0.7)) + for i in range(1, 5): + gy = y0 + plot_h * i / 4 + drawing.add(Line(x0, gy, x0 + plot_w, gy, strokeColor=colors.HexColor("#e5e7eb"), strokeWidth=0.4)) + group_w = plot_w / max(len(items), 1) + bar_w = min(28, max(9, group_w / 3.2)) + for idx, (label, actual, baseline) in enumerate(items): + center = x0 + group_w * idx + group_w / 2 + ah = plot_h * actual / max_v + bh = plot_h * baseline / max_v + drawing.add(Rect(center - bar_w - 1, y0, bar_w, ah, fillColor=colors.HexColor("#2563eb"), strokeColor=None)) + drawing.add(Rect(center + 1, y0, bar_w, bh, fillColor=colors.HexColor("#64748b"), strokeColor=None)) + drawing.add(String(center - 28, 12, label, fontName=APP_FONT, fontSize=6, fillColor=colors.HexColor("#6b7280"))) + drawing.add(Rect(x0, 2, 7, 7, fillColor=colors.HexColor("#2563eb"), strokeColor=None)) + drawing.add(String(x0 + 10, 2, actual_label, fontName=APP_FONT, fontSize=6.5, fillColor=colors.HexColor("#111827"))) + drawing.add(Rect(x0 + 150, 2, 7, 7, fillColor=colors.HexColor("#64748b"), strokeColor=None)) + drawing.add(String(x0 + 160, 2, baseline_label, fontName=APP_FONT, fontSize=6.5, fillColor=colors.HexColor("#111827"))) + return drawing + + @app.post("/api/export/pdf") def export_pdf(req: SimulationRequest): result = simulate(req) @@ -366,12 +449,18 @@ def export_pdf(req: SimulationRequest): story.append(Spacer(1, 0.35 * cm)) s = result.summary + simulated_costs = _sum_rows(result.schedule, lambda r: r.interest_part + r.overpayment_fee) + nominal_costs = _sum_rows(result.baseline_schedule, lambda r: r.interest_part) + simulated_payments = _sum_rows(result.schedule, lambda r: r.payment + r.overpayment + r.overpayment_fee) + nominal_payments = _sum_rows(result.baseline_schedule, lambda r: r.payment) summary = [ ["Liczba rat", s.months, "Bazowo", s.baseline_months], ["Suma zaplacona", _money(s.total_paid), "Suma odsetek", _money(s.total_interest)], ["Oszczednosc na odsetkach", _money(s.interest_saved), "Skrocenie okresu", f"{s.months_saved} mies."], ["Suma nadplat", _money(s.total_overpayment), "Prowizje nadplat", _money(s.total_overpayment_fees)], ["Srednia / maks. rata", f"{_money(s.average_payment)} / {_money(s.max_payment)}", "Data splaty", s.payoff_date or "-"], + ["Koszty nominalne", _money(nominal_costs), "Koszty po symulacji", _money(simulated_costs)], + ["Platnosci nominalne", _money(nominal_payments), "Platnosci po symulacji", _money(simulated_payments)], ] story.append(Paragraph("Podsumowanie", styles["Heading2"])) story.append(Table(summary, colWidths=[4.3 * cm, 4.25 * cm, 3.7 * cm, 4.35 * cm], style=TableStyle([ @@ -383,19 +472,35 @@ def export_pdf(req: SimulationRequest): ("FONTSIZE", (0, 0), (-1, -1), 7.0), ("VALIGN", (0, 0), (-1, -1), "TOP"), ]))) + story.append(Spacer(1, 0.2 * cm)) + result_description = ( + f"Opis wyniku: nominalne koszty kredytu wynosza {_money(nominal_costs)}, " + f"a po symulowanych modyfikacjach {_money(simulated_costs)}. " + f"Roznica kosztow to {_money(nominal_costs - simulated_costs)}. " + f"Laczne platnosci nominalne wynosza {_money(nominal_payments)}, " + f"a po symulacji {_money(simulated_payments)}. " + f"Kredyt konczy sie po {s.months} miesiacach zamiast {s.baseline_months}." + ) + story.append(Paragraph(result_description, styles["Small"])) story.append(Spacer(1, 0.35 * cm)) story.append(Paragraph("Wykresy", styles["Heading2"])) yearly = {} for row in result.schedule: year = math.ceil(row.month / 12) - yearly[year] = yearly.get(year, 0.0) + row.interest_part - story.append(_line_chart(result.schedule, "remaining", "Saldo kredytu w czasie")) + yearly[year] = yearly.get(year, 0.0) + row.interest_part + row.overpayment_fee + story.append(_comparison_line_chart(result.schedule, result.baseline_schedule, "remaining", "Saldo: nominalnie vs po modyfikacjach", "po modyfikacjach", "nominalnie")) story.append(Spacer(1, 0.15 * cm)) - story.append(_line_chart(result.schedule, "payment", "Rata w czasie")) + story.append(_comparison_line_chart(result.schedule, result.baseline_schedule, "payment", "Rata: nominalnie vs po modyfikacjach", "po modyfikacjach", "nominalnie")) + story.append(Spacer(1, 0.15 * cm)) + story.append(_comparison_bar_chart([ + ("Koszty", simulated_costs, nominal_costs), + ("Platnosci", simulated_payments, nominal_payments), + ("Odsetki", s.total_interest, s.baseline_interest), + ], "Porownanie oplat nominalnych i po symulacji", "po symulacji", "nominalnie")) story.append(Spacer(1, 0.15 * cm)) story.append(Table([[ - _bar_chart(yearly, "Odsetki rocznie", width=285, height=150), + _bar_chart(yearly, "Koszty rocznie po symulacji", width=285, height=150), _pie_chart([ ("Kapital", req.principal, "#2563eb"), ("Odsetki", s.total_interest, "#64748b"), diff --git a/app/models.py b/app/models.py index 3fa2f98..0dbcf60 100644 --- a/app/models.py +++ b/app/models.py @@ -97,4 +97,5 @@ class Summary(BaseModel): class SimulationResponse(BaseModel): schedule: list[ScheduleRow] + baseline_schedule: list[ScheduleRow] = Field(default_factory=list) summary: Summary diff --git a/app/simulator.py b/app/simulator.py index cbb6c38..c6e47b6 100644 --- a/app/simulator.py +++ b/app/simulator.py @@ -269,4 +269,4 @@ def simulate(req: SimulationRequest) -> SimulationResponse: max_payment=_round_money(max(payments)) if payments else 0, payoff_date=actual[-1].due_date if actual else None, ) - return SimulationResponse(schedule=actual, summary=summary) + return SimulationResponse(schedule=actual, baseline_schedule=baseline, summary=summary) diff --git a/app/static/app.js b/app/static/app.js index 8028f7f..f6e4d0e 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -34,6 +34,41 @@ function defaultProtection() { }; } +function sumRows(rows, selector) { + return (rows || []).reduce((acc, row) => acc + Number(selector(row) || 0), 0); +} + +function comparisonMetrics(data) { + const schedule = data?.schedule || []; + const baseline = data?.baseline_schedule || []; + const simulatedCosts = sumRows(schedule, r => r.interest_part + r.overpayment_fee); + const nominalCosts = sumRows(baseline, r => r.interest_part); + const simulatedPayments = sumRows(schedule, r => r.payment + r.overpayment + r.overpayment_fee); + const nominalPayments = sumRows(baseline, r => r.payment); + return { + simulatedCosts, + nominalCosts, + simulatedPayments, + nominalPayments, + costDiff: nominalCosts - simulatedCosts, + paymentDiff: nominalPayments - simulatedPayments + }; +} + +function updateProtectionHint() { + const defaults = defaultProtection(); + const hasProtection = defaults.commission > 0 || defaults.until !== ''; + const rows = [...document.querySelectorAll('#overpayments .dynamic-row')]; + const hasMismatch = rows.some(row => { + const commission = Number(row.querySelector('[data-field="commission"]')?.value || 0); + const until = Number(row.querySelector('[data-field="commissionUntil"]')?.value || 0) || ''; + return commission !== defaults.commission || until !== defaults.until; + }); + const shouldWarn = hasProtection && rows.length > 0 && hasMismatch; + $('applyProtection')?.classList.toggle('protection-warning', shouldWarn); + $('protectionBox')?.classList.toggle('protection-warning-box', shouldWarn); +} + function applyProtectionToOverpayments() { const defaults = defaultProtection(); document.querySelectorAll('#overpayments .dynamic-row').forEach(row => { @@ -42,6 +77,7 @@ function applyProtectionToOverpayments() { if (commissionInput) commissionInput.value = defaults.commission || 0; if (untilInput) untilInput.value = defaults.until || ''; }); + updateProtectionHint(); recalc(); } @@ -119,6 +155,7 @@ function buildRequest() { } function recalc() { + updateProtectionHint(); clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { lastRequest = buildRequest(); @@ -139,6 +176,21 @@ function addRateRow(month = 13, rate = 7.0) { recalc(); } +function syncOverpaymentAmountSlider(row, source) { + const amountInput = row.querySelector('[data-field="amount"]'); + const slider = row.querySelector('[data-field="amountSlider"]'); + const label = row.querySelector('[data-field="amountSliderLabel"]'); + if (!amountInput || !slider || !label) return; + const max = Number(slider.max); + const min = Number(slider.min); + if (source === 'slider') amountInput.value = slider.value; + if (source === 'input') { + const amount = Number(amountInput.value || 0); + slider.value = Math.min(max, Math.max(min, amount)); + } + label.textContent = money(Number(amountInput.value || 0)); +} + function addOverpaymentRow(month = 12, amount = 10000, repeat = 'once', until = '', commission = null, commissionUntil = null) { const defaults = defaultProtection(); if (commission === null || commission === undefined || commission === '') commission = defaults.commission; @@ -147,16 +199,20 @@ function addOverpaymentRow(month = 12, amount = 10000, repeat = 'once', until = div.className = 'dynamic-row overpay'; div.innerHTML = `
-
+
- `; + +
${money(amount)}
`; div.querySelector('[data-field="repeat"]').value = repeat; div.querySelector('button').onclick = () => { div.remove(); recalc(); }; - div.querySelectorAll('input,select').forEach(x => x.addEventListener('input', recalc)); + div.querySelector('[data-field="amountSlider"]').addEventListener('input', () => { syncOverpaymentAmountSlider(div, 'slider'); recalc(); }); + div.querySelector('[data-field="amount"]').addEventListener('input', () => { syncOverpaymentAmountSlider(div, 'input'); recalc(); }); + div.querySelectorAll('input:not([data-field="amount"]):not([data-field="amountSlider"]),select').forEach(x => x.addEventListener('input', recalc)); $('overpayments').appendChild(div); + syncOverpaymentAmountSlider(div, 'input'); recalc(); } @@ -192,7 +248,8 @@ function render(data) { ['Średnia rata', money(s.average_payment)] ].map(([label, value]) => `
${label}
${value}
`).join(''); - $('resultText').textContent = `Dzięki nadpłatom ${money(s.total_overpayment)} oszczędność netto wynosi około ${money(s.interest_saved)} po uwzględnieniu prowizji ${money(s.total_overpayment_fees)}. Kredyt kończy się ${s.payoff_date || '—'} po ${s.months} miesiącach zamiast ${s.baseline_months}. Odsetki: ${money(s.total_interest)}.`; + const c = comparisonMetrics(data); + $('resultText').textContent = `Dzięki nadpłatom ${money(s.total_overpayment)} oszczędność netto wynosi około ${money(s.interest_saved)} po uwzględnieniu prowizji ${money(s.total_overpayment_fees)}. Kredyt kończy się ${s.payoff_date || '—'} po ${s.months} miesiącach zamiast ${s.baseline_months}. Porównanie kosztów: nominalnie ${money(c.nominalCosts)}, po symulacji ${money(c.simulatedCosts)}, różnica ${money(c.costDiff)}. Porównanie łącznych płatności: nominalnie ${money(c.nominalPayments)}, po symulacji ${money(c.simulatedPayments)}, różnica ${money(c.paymentDiff)}.`; $('scheduleTable').innerHTML = data.schedule.slice(0, 240).map(r => `${r.month}${r.due_date}${r.days}${r.rate.toFixed(2)}%${money(r.payment)}${money(r.principal_part)}${money(r.interest_part)}${money(r.overpayment)}${money(r.overpayment_fee)}${money(r.cumulative_cost)}${money(r.remaining)}`).join(''); @@ -201,23 +258,37 @@ function render(data) { function renderCharts(data) { if (!data) return; - const labels = data.schedule.map(r => r.month); - const balance = data.schedule.map(r => r.remaining); - const payment = data.schedule.map(r => r.payment); + const maxMonths = Math.max(data.schedule.length, (data.baseline_schedule || []).length); + const labels = Array.from({ length: maxMonths }, (_, i) => i + 1); + const balance = labels.map(m => data.schedule[m - 1]?.remaining ?? null); + const payment = labels.map(m => data.schedule[m - 1]?.payment ?? null); + const baselineBalance = labels.map(m => data.baseline_schedule?.[m - 1]?.remaining ?? null); + const baselinePayment = labels.map(m => data.baseline_schedule?.[m - 1]?.payment ?? null); const principal = data.schedule.map(r => r.principal_part); const interest = data.schedule.map(r => r.interest_part); const cumulativeCost = data.schedule.map(r => r.cumulative_cost); const overpayments = data.schedule.map(r => r.overpayment); const yearly = new Map(); + const yearlyBaseline = new Map(); data.schedule.forEach(r => { const year = Math.ceil(r.month / 12); - yearly.set(year, (yearly.get(year) || 0) + r.interest_part); + yearly.set(year, (yearly.get(year) || 0) + r.interest_part + r.overpayment_fee); }); + (data.baseline_schedule || []).forEach(r => { + const year = Math.ceil(r.month / 12); + yearlyBaseline.set(year, (yearlyBaseline.get(year) || 0) + r.interest_part); + }); + const yearLabels = Array.from(new Set([...yearly.keys(), ...yearlyBaseline.keys()])).sort((a, b) => a - b); lineChart?.destroy(); lineChart = new Chart($('lineChart'), { type: 'line', - data: { labels, datasets: [{ label: 'Saldo', data: balance, yAxisID: 'y' }, { label: 'Rata', data: payment, yAxisID: 'y1' }] }, + data: { labels, datasets: [ + { label: 'Saldo po modyfikacjach', data: balance, yAxisID: 'y' }, + { label: 'Saldo nominalne', data: baselineBalance, yAxisID: 'y' }, + { label: 'Rata po modyfikacjach', data: payment, yAxisID: 'y1' }, + { label: 'Rata nominalna', data: baselinePayment, yAxisID: 'y1' } + ] }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, scales: { y: { beginAtZero: true }, y1: { position: 'right', beginAtZero: true, grid: { drawOnChartArea: false } } } } }); @@ -228,10 +299,14 @@ function renderCharts(data) { options: { responsive: true, maintainAspectRatio: false } }); + barChart?.destroy(); barChart = new Chart($('barChart'), { type: 'bar', - data: { labels: [...yearly.keys()].map(x => `Rok ${x}`), datasets: [{ label: 'Odsetki', data: [...yearly.values()] }] }, + data: { labels: yearLabels.map(x => `Rok ${x}`), datasets: [ + { label: 'Koszty nominalne', data: yearLabels.map(x => yearlyBaseline.get(x) || 0) }, + { label: 'Koszty po symulacji', data: yearLabels.map(x => yearly.get(x) || 0) } + ] }, options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true } } } }); @@ -328,7 +403,8 @@ async function loadNbp() { } } -['principal','baseRate','margin','installmentType','overpaymentEffect','loanStartDate','dueDay','moveDueDate','protectionMonths','protectionCommission'].forEach(id => $(id).addEventListener('input', recalc)); +['principal','baseRate','margin','installmentType','overpaymentEffect','loanStartDate','dueDay','moveDueDate'].forEach(id => $(id).addEventListener('input', recalc)); +['protectionMonths','protectionCommission'].forEach(id => $(id).addEventListener('input', () => { updateProtectionHint(); recalc(); })); $('termMonths').addEventListener('input', () => { updateTermLabel(); recalc(); }); $('yearsSlider').addEventListener('input', setMonthsFromYearsSlider); $('addRate').onclick = () => addRateRow(); diff --git a/app/static/index.html b/app/static/index.html index f308d08..18eb0a2 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -101,7 +101,7 @@ -
+
diff --git a/app/static/styles.css b/app/static/styles.css index 57de217..e9adc41 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -102,7 +102,7 @@ body { padding: .55rem; border-radius: 12px; } -.dynamic-row.overpay { grid-template-columns: .65fr .9fr .7fr .9fr .9fr .9fr auto; } +.dynamic-row.overpay { grid-template-columns: .65fr 1.35fr .7fr .9fr .9fr .9fr auto; } .dynamic-row.historical { grid-template-columns: .65fr .8fr 1fr .9fr .7fr 1.2fr auto; } .stat-card { border-radius: 16px; } .stat-value { font-size: 1.15rem; font-weight: 700; } @@ -148,4 +148,23 @@ body[data-theme="dark"] hr { border-color: var(--border); opacity: 1; } .slim-card > .card-body::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.28); -} \ No newline at end of file +} +.overpay-slider { grid-column: 1 / -1; min-width: 0; } +.overpay-slider .form-range { width: 100%; } +.overpay-amount { min-width: 0; } +.protection-box { + border: 1px solid transparent; + border-radius: 14px; + padding: .35rem .25rem; + transition: border-color .15s ease, background-color .15s ease; +} +.protection-warning-box { + border-color: #f59e0b; + background: rgba(245, 158, 11, .10); +} +.btn.protection-warning { + color: #111827; + background: #fbbf24; + border-color: #f59e0b; + box-shadow: 0 0 0 .2rem rgba(245, 158, 11, .20); +}