changes3
This commit is contained in:
+112
-7
@@ -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"),
|
||||
|
||||
@@ -97,4 +97,5 @@ class Summary(BaseModel):
|
||||
|
||||
class SimulationResponse(BaseModel):
|
||||
schedule: list[ScheduleRow]
|
||||
baseline_schedule: list[ScheduleRow] = Field(default_factory=list)
|
||||
summary: Summary
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+87
-11
@@ -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><label class="form-label">Miesiąc</label><input data-field="month" type="number" min="1" class="form-control form-control-sm" value="${month}"></div>
|
||||
<div><label class="form-label">Kwota</label><input data-field="amount" type="number" min="1" class="form-control form-control-sm" value="${amount}"></div>
|
||||
<div class="overpay-amount"><label class="form-label">Kwota</label><input data-field="amount" type="number" min="1" class="form-control form-control-sm" value="${amount}"></div>
|
||||
<div><label class="form-label">Prowizja %</label><input data-field="commission" type="number" min="0" step="0.01" class="form-control form-control-sm" value="${commission}"></div>
|
||||
<div><label class="form-label">Prowizja do mies.</label><input data-field="commissionUntil" type="number" min="1" class="form-control form-control-sm" value="${commissionUntil || ''}" placeholder="bez limitu"></div>
|
||||
<div><label class="form-label">Powtarzaj</label><select data-field="repeat" class="form-select form-select-sm"><option value="once">raz</option><option value="monthly">co miesiąc</option><option value="yearly">co rok</option></select></div>
|
||||
<div><label class="form-label">Nadpłaty do mies.</label><input data-field="until" type="number" min="1" class="form-control form-control-sm" value="${until || ''}"></div>
|
||||
<button class="btn btn-sm btn-outline-danger" type="button">Usuń</button>`;
|
||||
<button class="btn btn-sm btn-outline-danger" type="button">Usuń</button>
|
||||
<div class="overpay-slider mt-1"><div class="d-flex justify-content-between align-items-center"><label class="form-label mb-0">Suwak kwoty</label><span data-field="amountSliderLabel" class="small text-muted">${money(amount)}</span></div><input data-field="amountSlider" type="range" min="0" max="50000" step="100" class="form-range" value="${Math.min(50000, Number(amount || 0))}"></div>`;
|
||||
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]) => `<div class="col-6 col-md-4 col-xxl-3"><div class="card border-0 shadow-sm stat-card"><div class="card-body py-3"><div class="stat-label">${label}</div><div class="stat-value">${value}</div></div></div></div>`).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 => `<tr><td>${r.month}</td><td>${r.due_date}</td><td>${r.days}</td><td>${r.rate.toFixed(2)}%</td><td>${money(r.payment)}</td><td>${money(r.principal_part)}</td><td>${money(r.interest_part)}</td><td>${money(r.overpayment)}</td><td>${money(r.overpayment_fee)}</td><td>${money(r.cumulative_cost)}</td><td>${money(r.remaining)}</td></tr>`).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();
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
</div>
|
||||
<button id="applyProtection" class="btn btn-sm btn-outline-secondary" type="button">Uzupełnij</button>
|
||||
</div>
|
||||
<div class="row g-2 align-items-end">
|
||||
<div id="protectionBox" class="row g-2 align-items-end protection-box">
|
||||
<div class="col-6">
|
||||
<label class="form-label">Prowizja %</label>
|
||||
<input id="protectionCommission" type="number" min="0" max="20" step="0.01" class="form-control form-control-sm" value="0">
|
||||
|
||||
+20
-1
@@ -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; }
|
||||
@@ -149,3 +149,22 @@ 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);
|
||||
}
|
||||
.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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user