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 = `
- + - `; + + `; 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]) => `