This commit is contained in:
Mateusz Gruszczyński
2026-06-03 13:48:15 +02:00
parent 3a4e1b90e2
commit 4bdb20d9f5
6 changed files with 223 additions and 22 deletions
+112 -7
View File
@@ -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"),
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+1 -1
View File
@@ -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">
+21 -2
View File
@@ -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);
}
}
.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);
}