This commit is contained in:
Mateusz Gruszczyński
2026-06-03 13:18:08 +02:00
parent 7cb2eddafe
commit 3a4e1b90e2
6 changed files with 107 additions and 19 deletions
+6 -5
View File
@@ -345,12 +345,13 @@ def export_pdf(req: SimulationRequest):
story.append(Spacer(1, 0.25 * cm))
params = [
["Kwota kredytu", _money(req.principal), "Okres", f"{req.years} lat"],
["Kwota kredytu", _money(req.principal), "Okres", f"{req.term_months or req.years * 12} mies. ({round((req.term_months or req.years * 12) / 12, 1)} lat)"],
["Stopa bazowa", _pct(req.base_rate), "Marza", _pct(req.margin)],
["Oprocentowanie startowe", _pct(req.base_rate + req.margin), "Typ rat", _installment_label(req.installment_type.value)],
["Efekt nadplat", _effect_label(req.overpayment_effect.value), "Liczba rat po symulacji", str(result.summary.months)],
["Data startu", req.loan_start_date.isoformat(), "Dzien splaty", str(req.due_day)],
["Przesuwaj dni wolne", "tak" if req.move_due_date_to_business_day else "nie", "Data konca", result.summary.payoff_date or "-"],
["Okres ochronny nadplat", str(req.overpayment_protection_months or "brak"), "Domyslna prowizja", _pct(req.overpayment_protection_commission_percent)],
]
story.append(Paragraph("Parametry wejściowe", styles["Heading2"]))
story.append(Table(params, colWidths=[4.9 * cm, 3.4 * cm, 4.4 * cm, 3.9 * cm], style=TableStyle([
@@ -418,13 +419,13 @@ def export_pdf(req: SimulationRequest):
story.append(Spacer(1, 0.35 * cm))
story.append(Paragraph("Nadplaty", styles["Heading2"]))
over_rows = [["Miesiac", "Kwota", "Prowizja", "Powtarzanie", "Do miesiaca"]]
over_rows = [["Miesiac", "Kwota", "Prowizja", "Prow. do mies.", "Powtarzanie", "Nadplaty do mies."]]
if req.overpayments:
for op in sorted(req.overpayments, key=lambda x: x.month):
over_rows.append([str(op.month), _money(op.amount), _pct(op.commission_percent), _repeat_label(op.repeat), str(op.until_month or "-")])
over_rows.append([str(op.month), _money(op.amount), _pct(op.commission_percent), str(op.commission_until_month or "bez limitu"), _repeat_label(op.repeat), str(op.until_month or "-")])
else:
over_rows.append(["-", "-", "-", "-", "-"])
story.append(Table(over_rows, repeatRows=1, colWidths=[2.4 * cm, 3.4 * cm, 2.4 * cm, 3.4 * cm, 3.4 * cm], style=TableStyle([
over_rows.append(["-", "-", "-", "-", "-", "-"])
story.append(Table(over_rows, repeatRows=1, colWidths=[1.8 * cm, 2.8 * cm, 2.1 * cm, 2.7 * cm, 2.8 * cm, 2.8 * cm], style=TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e5e7eb")),
("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#cbd5e1")),
("FONTNAME", (0, 0), (-1, -1), APP_FONT),
+5 -1
View File
@@ -33,6 +33,7 @@ class Overpayment(BaseModel):
repeat: Literal["once", "monthly", "yearly"] = "once"
until_month: int | None = Field(default=None, ge=1)
commission_percent: float = Field(default=0, ge=0, le=20, description="Prowizja od nadplaty w procentach")
commission_until_month: int | None = Field(default=None, ge=1, description="Ostatni miesiac naliczania prowizji od nadplaty")
class HistoricalMonth(BaseModel):
@@ -46,7 +47,8 @@ class HistoricalMonth(BaseModel):
class SimulationRequest(BaseModel):
principal: float = Field(gt=0)
years: int = Field(ge=1, le=50)
years: int = Field(default=25, ge=1, le=50)
term_months: int | None = Field(default=None, ge=1, le=600, description="Okres kredytu w miesiacach")
margin: float = Field(ge=0, le=20, default=2.0)
base_rate: float = Field(ge=0, le=30, default=5.75)
installment_type: InstallmentType = InstallmentType.equal
@@ -54,6 +56,8 @@ class SimulationRequest(BaseModel):
loan_start_date: date = Field(default_factory=date.today)
due_day: int = Field(default=5, ge=1, le=28, description="Dzien splaty raty")
move_due_date_to_business_day: bool = True
overpayment_protection_months: int | None = Field(default=None, ge=1, description="Okres ochronny prowizji od nadplat w miesiacach")
overpayment_protection_commission_percent: float = Field(default=0, ge=0, le=20, description="Domyslna prowizja od nadplat w okresie ochronnym")
rate_changes: list[RateChange] = Field(default_factory=list)
overpayments: list[Overpayment] = Field(default_factory=list)
historical_months: list[HistoricalMonth] = Field(default_factory=list)
+7 -2
View File
@@ -127,7 +127,8 @@ def _scheduled_overpayment_for_month(req: SimulationRequest, month: int) -> tupl
active = True
if active:
total += op.amount
fee += op.amount * op.commission_percent / 100
if op.commission_until_month is None or month <= op.commission_until_month:
fee += op.amount * op.commission_percent / 100
return total, fee
@@ -144,9 +145,13 @@ def _overpayment_for_month(req: SimulationRequest, month: int, historical: dict[
return scheduled_amount + hist_amount, scheduled_fee + hist_fee
def _term_months(req: SimulationRequest) -> int:
return int(req.term_months or req.years * 12)
def _simulate_raw(req: SimulationRequest, include_overpayments: bool = True) -> list[ScheduleRow]:
balance = float(req.principal)
total_months = int(req.years * 12)
total_months = _term_months(req)
rows: list[ScheduleRow] = []
fixed_payment: float | None = None
month = 1
+57 -7
View File
@@ -13,6 +13,38 @@ function todayIso() {
return d.toISOString().slice(0, 10);
}
function updateTermLabel() {
const months = Math.max(1, Number($('termMonths').value || 1));
const years = Math.max(1, Math.round(months / 12));
$('yearsSlider').value = Math.min(50, years);
$('yearsSliderLabel').textContent = `${months} mies. ≈ ${(months / 12).toFixed(1)} lat`;
}
function setMonthsFromYearsSlider() {
const years = Number($('yearsSlider').value || 1);
$('termMonths').value = years * 12;
$('yearsSliderLabel').textContent = `${years} lat = ${years * 12} mies.`;
recalc();
}
function defaultProtection() {
return {
commission: Number($('protectionCommission')?.value || 0),
until: Number($('protectionMonths')?.value || 0) || ''
};
}
function applyProtectionToOverpayments() {
const defaults = defaultProtection();
document.querySelectorAll('#overpayments .dynamic-row').forEach(row => {
const commissionInput = row.querySelector('[data-field="commission"]');
const untilInput = row.querySelector('[data-field="commissionUntil"]');
if (commissionInput) commissionInput.value = defaults.commission || 0;
if (untilInput) untilInput.value = defaults.until || '';
});
recalc();
}
function setTheme(theme) {
document.body.dataset.theme = theme;
localStorage.setItem('mortgage-theme', theme);
@@ -49,7 +81,8 @@ function buildRequest() {
amount: Number(row.querySelector('[data-field="amount"]').value || 0),
repeat: row.querySelector('[data-field="repeat"]').value,
until_month: Number(row.querySelector('[data-field="until"]').value || 0) || null,
commission_percent: Number(row.querySelector('[data-field="commission"]').value || 0)
commission_percent: Number(row.querySelector('[data-field="commission"]').value || 0),
commission_until_month: Number(row.querySelector('[data-field="commissionUntil"]').value || 0) || null
})).filter(x => x.month > 0 && x.amount > 0);
const historicalMonths = [...document.querySelectorAll('#historicalMonths .dynamic-row')].map(row => {
@@ -64,9 +97,12 @@ function buildRequest() {
};
}).filter(x => x.month > 0);
const termMonths = Math.max(1, Math.round(num('termMonths') || 1));
return {
principal: num('principal'),
years: num('years'),
years: Math.max(1, Math.ceil(termMonths / 12)),
term_months: termMonths,
margin: num('margin'),
base_rate: num('baseRate'),
installment_type: $('installmentType').value,
@@ -74,6 +110,8 @@ function buildRequest() {
loan_start_date: $('loanStartDate').value || todayIso(),
due_day: num('dueDay') || 5,
move_due_date_to_business_day: $('moveDueDate').checked,
overpayment_protection_months: Number($('protectionMonths').value || 0) || null,
overpayment_protection_commission_percent: Number($('protectionCommission').value || 0),
rate_changes: rateChanges,
overpayments: overpayments,
historical_months: historicalMonths
@@ -101,15 +139,19 @@ function addRateRow(month = 13, rate = 7.0) {
recalc();
}
function addOverpaymentRow(month = 12, amount = 10000, repeat = 'once', until = '', commission = 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;
if (commissionUntil === null || commissionUntil === undefined || commissionUntil === '') commissionUntil = defaults.until;
const div = document.createElement('div');
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><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">Do mies.</label><input data-field="until" type="number" min="1" class="form-control form-control-sm" value="${until || ''}"></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>`;
div.querySelector('[data-field="repeat"]').value = repeat;
div.querySelector('button').onclick = () => { div.remove(); recalc(); };
@@ -237,7 +279,9 @@ function clearRows() {
function applyRequest(data) {
$('principal').value = data.principal ?? 600000;
$('years').value = data.years ?? 25;
const loadedMonths = data.term_months ?? ((data.years ?? 25) * 12);
$('termMonths').value = loadedMonths;
updateTermLabel();
$('margin').value = data.margin ?? 2;
$('baseRate').value = data.base_rate ?? 5.75;
$('installmentType').value = data.installment_type ?? 'equal';
@@ -245,9 +289,11 @@ function applyRequest(data) {
$('loanStartDate').value = data.loan_start_date ?? todayIso();
$('dueDay').value = data.due_day ?? 5;
$('moveDueDate').checked = data.move_due_date_to_business_day ?? true;
$('protectionMonths').value = data.overpayment_protection_months ?? '';
$('protectionCommission').value = data.overpayment_protection_commission_percent ?? 0;
clearRows();
(data.rate_changes || []).forEach(x => addRateRow(x.month, x.annual_rate));
(data.overpayments || []).forEach(x => addOverpaymentRow(x.month, x.amount, x.repeat, x.until_month || '', x.commission_percent || 0));
(data.overpayments || []).forEach(x => addOverpaymentRow(x.month, x.amount, x.repeat, x.until_month || '', x.commission_percent || 0, x.commission_until_month || ''));
(data.historical_months || []).forEach(x => addHistoricalRow(x.month, x.annual_rate ?? '', x.grace_type || 'none', x.overpayment || 0, x.overpayment_commission_percent || 0, x.note || ''));
recalc();
}
@@ -282,9 +328,12 @@ async function loadNbp() {
}
}
['principal','years','baseRate','margin','installmentType','overpaymentEffect','loanStartDate','dueDay','moveDueDate'].forEach(id => $(id).addEventListener('input', recalc));
['principal','baseRate','margin','installmentType','overpaymentEffect','loanStartDate','dueDay','moveDueDate','protectionMonths','protectionCommission'].forEach(id => $(id).addEventListener('input', recalc));
$('termMonths').addEventListener('input', () => { updateTermLabel(); recalc(); });
$('yearsSlider').addEventListener('input', setMonthsFromYearsSlider);
$('addRate').onclick = () => addRateRow();
$('addOverpayment').onclick = () => addOverpaymentRow();
$('applyProtection').onclick = applyProtectionToOverpayments;
$('addHistorical').onclick = () => addHistoricalRow();
$('exportCsv').onclick = () => download('/api/export/csv', 'symulacja-kredytu.csv');
$('exportPdf').onclick = () => download('/api/export/pdf', 'symulacja-kredytu.pdf');
@@ -293,6 +342,7 @@ $('importJson').onclick = () => $('jsonFile').click();
$('jsonFile').onchange = (e) => e.target.files?.[0] && importJsonFile(e.target.files[0]);
$('loadNbp').onclick = loadNbp;
$('loanStartDate').value = todayIso();
updateTermLabel();
initTheme();
addOverpaymentRow(24, 20000, 'once', '', 0);
+29 -3
View File
@@ -13,7 +13,7 @@
<div class="card-body d-flex flex-wrap align-items-center justify-content-between gap-3">
<div>
<h1 class="h3 mb-1">Symulator kredytu hipotecznego</h1>
<p class="text-muted mb-0">@linuiarz.pl</p>
<p class="text-muted mb-0">@linuxiarz.pl</p>
</div>
<div class="d-flex gap-2 flex-wrap justify-content-end">
<button id="themeToggle" class="btn btn-outline-secondary btn-sm" type="button" aria-label="Przełącz motyw">☀️ Jasny</button>
@@ -38,8 +38,15 @@
<input id="principal" type="number" class="form-control form-control-sm" value="600000" min="1">
</div>
<div class="col-6">
<label class="form-label">Okres lat</label>
<input id="years" type="number" class="form-control form-control-sm" value="25" min="1" max="50">
<label class="form-label">Okres w miesiącach</label>
<input id="termMonths" type="number" class="form-control form-control-sm" value="300" min="1" max="600">
</div>
<div class="col-12 term-slider-wrap">
<div class="d-flex justify-content-between align-items-center gap-2">
<label class="form-label mb-0" for="yearsSlider">Szybki wybór lat</label>
<span id="yearsSliderLabel" class="small text-muted">25 lat = 300 mies.</span>
</div>
<input id="yearsSlider" type="range" class="form-range" min="1" max="50" step="1" value="25">
</div>
<div class="col-6">
<label class="form-label">Stopa bazowa %</label>
@@ -86,6 +93,25 @@
</div>
<div id="rateChanges" class="stack"></div>
<hr>
<div class="d-flex justify-content-between align-items-start gap-2 mb-2">
<div>
<h3 class="h6 mb-0">Okres ochronny nadpłat</h3>
<div class="text-muted small">Domyślna prowizja automatycznie wpisywana do nowych nadpłat</div>
</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 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">
</div>
<div class="col-6">
<label class="form-label">Do miesiąca</label>
<input id="protectionMonths" type="number" min="1" class="form-control form-control-sm" placeholder="np. 36">
</div>
</div>
<hr>
<div class="d-flex justify-content-between align-items-center mb-2">
<h3 class="h6 mb-0">Nadpłaty</h3>
+3 -1
View File
@@ -88,6 +88,8 @@ body {
.chart-card canvas { max-height: 330px; }
.chart-card-sm canvas { max-height: 230px; }
.form-label { font-size: .78rem; margin-bottom: .2rem; }
.term-slider-wrap { padding: .25rem .1rem .15rem; }
.form-range { margin-bottom: 0; }
.form-control, .form-select { border-radius: 10px; }
.stack { display: grid; gap: .45rem; }
.dynamic-row {
@@ -100,7 +102,7 @@ body {
padding: .55rem;
border-radius: 12px;
}
.dynamic-row.overpay { grid-template-columns: .75fr 1fr .75fr .9fr .75fr auto; }
.dynamic-row.overpay { grid-template-columns: .65fr .9fr .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; }