zmiany
This commit is contained in:
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
FROM python:3.12-slim
|
FROM python:3.13-slim
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1
|
PYTHONUNBUFFERED=1
|
||||||
@@ -16,4 +16,4 @@ COPY app ./app
|
|||||||
RUN mkdir -p /app/exports
|
RUN mkdir -p /app/exports
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8147"]
|
||||||
|
|||||||
+133
-32
@@ -9,6 +9,7 @@ from datetime import datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from io import BytesIO
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Response
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Response
|
||||||
from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
|
||||||
@@ -21,6 +22,8 @@ from reportlab.pdfbase import pdfmetrics
|
|||||||
from reportlab.pdfbase.ttfonts import TTFont
|
from reportlab.pdfbase.ttfonts import TTFont
|
||||||
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak
|
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak
|
||||||
from reportlab.graphics.shapes import Drawing, Line, PolyLine, Rect, String, Wedge
|
from reportlab.graphics.shapes import Drawing, Line, PolyLine, Rect, String, Wedge
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
|
||||||
|
|
||||||
from .models import SimulationRequest
|
from .models import SimulationRequest
|
||||||
from .simulator import simulate
|
from .simulator import simulate
|
||||||
@@ -94,39 +97,107 @@ async def ws_simulate(websocket: WebSocket):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
await websocket.send_json({"error": str(exc)})
|
await websocket.send_json({"error": str(exc)})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/rate/nbp")
|
@app.get("/api/rate/nbp")
|
||||||
async def nbp_rate():
|
async def nbp_rate():
|
||||||
"""Pomocniczo pobiera stopę referencyjną NBP z oficjalnej strony HTML."""
|
"""
|
||||||
url = "https://nbp.pl/en/monetary-policy/mpc-decisions/interest-rates/"
|
Pobiera średnie oprocentowanie nowych kredytów mieszkaniowych PLN z XLSX NBP.
|
||||||
try:
|
Uwaga: to nie jest stopa referencyjna NBP, tylko średnie oprocentowanie kredytów.
|
||||||
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
|
"""
|
||||||
resp = await client.get(url, headers={"User-Agent": "mortgage-simulator/1.0"})
|
url = "https://static.nbp.pl/dane/statystyka/inne/stopy_proc_pl_srdW.xlsx"
|
||||||
resp.raise_for_status()
|
|
||||||
soup = BeautifulSoup(resp.text, "html.parser")
|
|
||||||
text = " ".join(soup.get_text(" ").split())
|
|
||||||
patterns = [
|
|
||||||
r"Reference rate[^0-9]{0,80}(\d+[\.,]\d+|\d+)\s*%",
|
|
||||||
r"minimum money market intervention rate[^0-9]{0,80}(\d+[\.,]\d+|\d+)\s*%",
|
|
||||||
]
|
|
||||||
for pattern in patterns:
|
|
||||||
match = re.search(pattern, text, flags=re.IGNORECASE)
|
|
||||||
if match:
|
|
||||||
value = float(match.group(1).replace(",", "."))
|
|
||||||
return {"source": "NBP", "url": url, "rate": value, "fetched_at": datetime.utcnow().isoformat() + "Z"}
|
|
||||||
return JSONResponse({"error": "Nie znaleziono stopy w treści strony NBP", "url": url}, status_code=502)
|
|
||||||
except Exception as exc:
|
|
||||||
return JSONResponse({"error": str(exc), "url": url}, status_code=502)
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
url,
|
||||||
|
headers={
|
||||||
|
"User-Agent": "Mozilla/5.0 mortgage-simulator/1.0",
|
||||||
|
"Accept": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,*/*",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
wb = load_workbook(BytesIO(resp.content), data_only=True, read_only=True)
|
||||||
|
ws = wb["4 OPN2PLN"]
|
||||||
|
|
||||||
|
target_row = None
|
||||||
|
|
||||||
|
for row in ws.iter_rows():
|
||||||
|
values = [cell.value for cell in row]
|
||||||
|
joined = " ".join(str(v).lower() for v in values if v is not None)
|
||||||
|
|
||||||
|
if "nieruchomości mieszkaniowe" in joined or "nieruchomosci mieszkaniowe" in joined:
|
||||||
|
target_row = values
|
||||||
|
break
|
||||||
|
|
||||||
|
if not target_row:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "Nie znaleziono wiersza: na nieruchomości mieszkaniowe",
|
||||||
|
"url": url,
|
||||||
|
"sheet": "4 OPN2PLN",
|
||||||
|
},
|
||||||
|
status_code=502,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Daty są w wierszu 2, wartości w znalezionym wierszu.
|
||||||
|
dates = [cell.value for cell in ws[2]]
|
||||||
|
|
||||||
|
latest_rate = None
|
||||||
|
latest_date = None
|
||||||
|
|
||||||
|
for idx in range(len(target_row) - 1, -1, -1):
|
||||||
|
value = target_row[idx]
|
||||||
|
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
latest_rate = float(value)
|
||||||
|
latest_date_cell = dates[idx] if idx < len(dates) else None
|
||||||
|
|
||||||
|
if isinstance(latest_date_cell, datetime):
|
||||||
|
latest_date = latest_date_cell.date().isoformat()
|
||||||
|
elif latest_date_cell:
|
||||||
|
latest_date = str(latest_date_cell)
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
if latest_rate is None:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "Nie znaleziono wartości liczbowej oprocentowania kredytu mieszkaniowego",
|
||||||
|
"url": url,
|
||||||
|
"sheet": "4 OPN2PLN",
|
||||||
|
},
|
||||||
|
status_code=502,
|
||||||
|
)
|
||||||
|
|
||||||
|
# W XLSX wartości są jako ułamek, np. 0.0591 = 5.91%.
|
||||||
|
rate_percent = round(latest_rate * 100, 4)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"source": "NBP",
|
||||||
|
"kind": "average_new_housing_loan_pln_rate",
|
||||||
|
"label": "Średnie oprocentowanie nowych kredytów mieszkaniowych PLN",
|
||||||
|
"url": url,
|
||||||
|
"sheet": "4 OPN2PLN",
|
||||||
|
"period": latest_date,
|
||||||
|
"rate": rate_percent,
|
||||||
|
"fetched_at": datetime.utcnow().isoformat() + "Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": str(exc), "url": url},
|
||||||
|
status_code=502,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/export/csv")
|
@app.post("/api/export/csv")
|
||||||
def export_csv(req: SimulationRequest):
|
def export_csv(req: SimulationRequest):
|
||||||
result = simulate(req)
|
result = simulate(req)
|
||||||
buf = io.StringIO()
|
buf = io.StringIO()
|
||||||
writer = csv.writer(buf, delimiter=";")
|
writer = csv.writer(buf, delimiter=";")
|
||||||
writer.writerow(["miesiac", "oprocentowanie", "rata", "kapital", "odsetki", "nadplata", "saldo"])
|
writer.writerow(["miesiac", "data_splaty", "dni", "oprocentowanie", "rata", "kapital", "odsetki", "nadplata", "prowizja_nadplaty", "saldo", "karencja", "odsetki_narastajaco", "koszt_narastajaco", "nadplaty_narastajaco"])
|
||||||
for row in result.schedule:
|
for row in result.schedule:
|
||||||
writer.writerow([row.month, row.rate, row.payment, row.principal_part, row.interest_part, row.overpayment, row.remaining])
|
writer.writerow([row.month, row.due_date, row.days, row.rate, row.payment, row.principal_part, row.interest_part, row.overpayment, row.overpayment_fee, row.remaining, row.grace_type.value, row.cumulative_interest, row.cumulative_cost, row.cumulative_overpayment])
|
||||||
writer.writerow([])
|
writer.writerow([])
|
||||||
writer.writerow(["Podsumowanie"])
|
writer.writerow(["Podsumowanie"])
|
||||||
for key, value in result.summary.model_dump().items():
|
for key, value in result.summary.model_dump().items():
|
||||||
@@ -278,6 +349,8 @@ def export_pdf(req: SimulationRequest):
|
|||||||
["Stopa bazowa", _pct(req.base_rate), "Marza", _pct(req.margin)],
|
["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)],
|
["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)],
|
["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 "-"],
|
||||||
]
|
]
|
||||||
story.append(Paragraph("Parametry wejściowe", styles["Heading2"]))
|
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([
|
story.append(Table(params, colWidths=[4.9 * cm, 3.4 * cm, 4.4 * cm, 3.9 * cm], style=TableStyle([
|
||||||
@@ -296,16 +369,17 @@ def export_pdf(req: SimulationRequest):
|
|||||||
["Liczba rat", s.months, "Bazowo", s.baseline_months],
|
["Liczba rat", s.months, "Bazowo", s.baseline_months],
|
||||||
["Suma zaplacona", _money(s.total_paid), "Suma odsetek", _money(s.total_interest)],
|
["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."],
|
["Oszczednosc na odsetkach", _money(s.interest_saved), "Skrocenie okresu", f"{s.months_saved} mies."],
|
||||||
["Suma nadplat", _money(s.total_overpayment), "Srednia / maks. rata", f"{_money(s.average_payment)} / {_money(s.max_payment)}"],
|
["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 "-"],
|
||||||
]
|
]
|
||||||
story.append(Paragraph("Podsumowanie", styles["Heading2"]))
|
story.append(Paragraph("Podsumowanie", styles["Heading2"]))
|
||||||
story.append(Table(summary, colWidths=[4.9 * cm, 3.4 * cm, 4.4 * cm, 3.9 * cm], style=TableStyle([
|
story.append(Table(summary, colWidths=[4.3 * cm, 4.25 * cm, 3.7 * cm, 4.35 * cm], style=TableStyle([
|
||||||
("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#cbd5e1")),
|
("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#cbd5e1")),
|
||||||
("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#ffffff")),
|
("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#ffffff")),
|
||||||
("FONTNAME", (0, 0), (-1, -1), APP_FONT),
|
("FONTNAME", (0, 0), (-1, -1), APP_FONT),
|
||||||
("FONTNAME", (0, 0), (0, -1), APP_FONT_BOLD),
|
("FONTNAME", (0, 0), (0, -1), APP_FONT_BOLD),
|
||||||
("FONTNAME", (2, 0), (2, -1), APP_FONT_BOLD),
|
("FONTNAME", (2, 0), (2, -1), APP_FONT_BOLD),
|
||||||
("FONTSIZE", (0, 0), (-1, -1), 7.4),
|
("FONTSIZE", (0, 0), (-1, -1), 7.0),
|
||||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||||
])))
|
])))
|
||||||
story.append(Spacer(1, 0.35 * cm))
|
story.append(Spacer(1, 0.35 * cm))
|
||||||
@@ -344,34 +418,61 @@ def export_pdf(req: SimulationRequest):
|
|||||||
story.append(Spacer(1, 0.35 * cm))
|
story.append(Spacer(1, 0.35 * cm))
|
||||||
|
|
||||||
story.append(Paragraph("Nadplaty", styles["Heading2"]))
|
story.append(Paragraph("Nadplaty", styles["Heading2"]))
|
||||||
over_rows = [["Miesiac", "Kwota", "Powtarzanie", "Do miesiaca"]]
|
over_rows = [["Miesiac", "Kwota", "Prowizja", "Powtarzanie", "Do miesiaca"]]
|
||||||
if req.overpayments:
|
if req.overpayments:
|
||||||
for op in sorted(req.overpayments, key=lambda x: x.month):
|
for op in sorted(req.overpayments, key=lambda x: x.month):
|
||||||
over_rows.append([str(op.month), _money(op.amount), _repeat_label(op.repeat), str(op.until_month or "-")])
|
over_rows.append([str(op.month), _money(op.amount), _pct(op.commission_percent), _repeat_label(op.repeat), str(op.until_month or "-")])
|
||||||
else:
|
else:
|
||||||
over_rows.append(["-", "-", "-", "-"])
|
over_rows.append(["-", "-", "-", "-", "-"])
|
||||||
story.append(Table(over_rows, repeatRows=1, colWidths=[3 * cm, 4 * cm, 4 * cm, 4 * cm], style=TableStyle([
|
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([
|
||||||
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e5e7eb")),
|
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e5e7eb")),
|
||||||
("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#cbd5e1")),
|
("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#cbd5e1")),
|
||||||
("FONTNAME", (0, 0), (-1, -1), APP_FONT),
|
("FONTNAME", (0, 0), (-1, -1), APP_FONT),
|
||||||
("FONTNAME", (0, 0), (-1, 0), APP_FONT_BOLD),
|
("FONTNAME", (0, 0), (-1, 0), APP_FONT_BOLD),
|
||||||
("FONTSIZE", (0, 0), (-1, -1), 8),
|
("FONTSIZE", (0, 0), (-1, -1), 8),
|
||||||
])))
|
])))
|
||||||
|
story.append(Spacer(1, 0.35 * cm))
|
||||||
|
|
||||||
|
story.append(Paragraph("Kredyt historyczny", styles["Heading2"]))
|
||||||
|
hist_rows = [["Miesiac", "Oprocentowanie", "Karencja", "Nadplata", "Prowizja", "Opis"]]
|
||||||
|
if req.historical_months:
|
||||||
|
for item in sorted(req.historical_months, key=lambda x: x.month):
|
||||||
|
hist_rows.append([
|
||||||
|
str(item.month),
|
||||||
|
_pct(item.annual_rate) if item.annual_rate is not None else "-",
|
||||||
|
item.grace_type.value,
|
||||||
|
_money(item.overpayment),
|
||||||
|
_pct(item.overpayment_commission_percent),
|
||||||
|
item.note or "-",
|
||||||
|
])
|
||||||
|
else:
|
||||||
|
hist_rows.append(["-", "-", "-", "-", "-", "-"])
|
||||||
|
story.append(Table(hist_rows, repeatRows=1, colWidths=[2 * cm, 2.6 * cm, 2.6 * cm, 2.8 * cm, 2.2 * cm, 4.5 * 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),
|
||||||
|
("FONTNAME", (0, 0), (-1, 0), APP_FONT_BOLD),
|
||||||
|
("FONTSIZE", (0, 0), (-1, -1), 7.5),
|
||||||
|
])))
|
||||||
story.append(PageBreak())
|
story.append(PageBreak())
|
||||||
|
|
||||||
story.append(Paragraph("Kompletny harmonogram wszystkich rat", styles["Heading2"]))
|
story.append(Paragraph("Kompletny harmonogram wszystkich rat", styles["Heading2"]))
|
||||||
schedule_rows = [["Mies.", "Oproc.", "Rata", "Kapital", "Odsetki", "Nadplata", "Saldo"]]
|
schedule_rows = [["Mies.", "Data", "Dni", "Oproc.", "Rata", "Kapital", "Odsetki", "Nadplata", "Prow.", "Koszt nar.", "Saldo"]]
|
||||||
for row in result.schedule:
|
for row in result.schedule:
|
||||||
schedule_rows.append([
|
schedule_rows.append([
|
||||||
row.month,
|
row.month,
|
||||||
|
row.due_date,
|
||||||
|
row.days,
|
||||||
_pct(row.rate),
|
_pct(row.rate),
|
||||||
_money(row.payment),
|
_money(row.payment),
|
||||||
_money(row.principal_part),
|
_money(row.principal_part),
|
||||||
_money(row.interest_part),
|
_money(row.interest_part),
|
||||||
_money(row.overpayment),
|
_money(row.overpayment),
|
||||||
|
_money(row.overpayment_fee),
|
||||||
|
_money(row.cumulative_cost),
|
||||||
_money(row.remaining),
|
_money(row.remaining),
|
||||||
])
|
])
|
||||||
table = Table(schedule_rows, repeatRows=1, colWidths=[1.35 * cm, 1.7 * cm, 2.55 * cm, 2.55 * cm, 2.55 * cm, 2.55 * cm, 3.0 * cm])
|
table = Table(schedule_rows, repeatRows=1, colWidths=[0.9 * cm, 1.75 * cm, 0.75 * cm, 1.15 * cm, 1.85 * cm, 1.85 * cm, 1.85 * cm, 1.85 * cm, 1.5 * cm, 2.0 * cm, 2.05 * cm])
|
||||||
table.setStyle(TableStyle([
|
table.setStyle(TableStyle([
|
||||||
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e5e7eb")),
|
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e5e7eb")),
|
||||||
("TEXTCOLOR", (0, 0), (-1, 0), colors.HexColor("#111827")),
|
("TEXTCOLOR", (0, 0), (-1, 0), colors.HexColor("#111827")),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -15,6 +16,12 @@ class OverpaymentEffect(str, Enum):
|
|||||||
lower_payment = "lower_payment"
|
lower_payment = "lower_payment"
|
||||||
|
|
||||||
|
|
||||||
|
class GraceType(str, Enum):
|
||||||
|
none = "none"
|
||||||
|
interest_only = "interest_only"
|
||||||
|
full = "full"
|
||||||
|
|
||||||
|
|
||||||
class RateChange(BaseModel):
|
class RateChange(BaseModel):
|
||||||
month: int = Field(ge=1, description="Miesiac od startu kredytu")
|
month: int = Field(ge=1, description="Miesiac od startu kredytu")
|
||||||
annual_rate: float = Field(ge=0, le=30, description="Roczne oprocentowanie procentowo")
|
annual_rate: float = Field(ge=0, le=30, description="Roczne oprocentowanie procentowo")
|
||||||
@@ -25,6 +32,16 @@ class Overpayment(BaseModel):
|
|||||||
amount: float = Field(gt=0)
|
amount: float = Field(gt=0)
|
||||||
repeat: Literal["once", "monthly", "yearly"] = "once"
|
repeat: Literal["once", "monthly", "yearly"] = "once"
|
||||||
until_month: int | None = Field(default=None, ge=1)
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
class HistoricalMonth(BaseModel):
|
||||||
|
month: int = Field(ge=1)
|
||||||
|
annual_rate: float | None = Field(default=None, ge=0, le=30)
|
||||||
|
grace_type: GraceType = GraceType.none
|
||||||
|
overpayment: float = Field(default=0, ge=0)
|
||||||
|
overpayment_commission_percent: float = Field(default=0, ge=0, le=20)
|
||||||
|
note: str = ""
|
||||||
|
|
||||||
|
|
||||||
class SimulationRequest(BaseModel):
|
class SimulationRequest(BaseModel):
|
||||||
@@ -34,18 +51,29 @@ class SimulationRequest(BaseModel):
|
|||||||
base_rate: float = Field(ge=0, le=30, default=5.75)
|
base_rate: float = Field(ge=0, le=30, default=5.75)
|
||||||
installment_type: InstallmentType = InstallmentType.equal
|
installment_type: InstallmentType = InstallmentType.equal
|
||||||
overpayment_effect: OverpaymentEffect = OverpaymentEffect.shorten
|
overpayment_effect: OverpaymentEffect = OverpaymentEffect.shorten
|
||||||
|
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
|
||||||
rate_changes: list[RateChange] = Field(default_factory=list)
|
rate_changes: list[RateChange] = Field(default_factory=list)
|
||||||
overpayments: list[Overpayment] = Field(default_factory=list)
|
overpayments: list[Overpayment] = Field(default_factory=list)
|
||||||
|
historical_months: list[HistoricalMonth] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class ScheduleRow(BaseModel):
|
class ScheduleRow(BaseModel):
|
||||||
month: int
|
month: int
|
||||||
|
due_date: str
|
||||||
|
days: int
|
||||||
rate: float
|
rate: float
|
||||||
payment: float
|
payment: float
|
||||||
principal_part: float
|
principal_part: float
|
||||||
interest_part: float
|
interest_part: float
|
||||||
overpayment: float
|
overpayment: float
|
||||||
|
overpayment_fee: float
|
||||||
remaining: float
|
remaining: float
|
||||||
|
grace_type: GraceType = GraceType.none
|
||||||
|
cumulative_interest: float
|
||||||
|
cumulative_cost: float
|
||||||
|
cumulative_overpayment: float
|
||||||
|
|
||||||
|
|
||||||
class Summary(BaseModel):
|
class Summary(BaseModel):
|
||||||
@@ -53,12 +81,14 @@ class Summary(BaseModel):
|
|||||||
total_paid: float
|
total_paid: float
|
||||||
total_interest: float
|
total_interest: float
|
||||||
total_overpayment: float
|
total_overpayment: float
|
||||||
|
total_overpayment_fees: float
|
||||||
interest_saved: float
|
interest_saved: float
|
||||||
months_saved: int
|
months_saved: int
|
||||||
baseline_interest: float
|
baseline_interest: float
|
||||||
baseline_months: int
|
baseline_months: int
|
||||||
average_payment: float
|
average_payment: float
|
||||||
max_payment: float
|
max_payment: float
|
||||||
|
payoff_date: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class SimulationResponse(BaseModel):
|
class SimulationResponse(BaseModel):
|
||||||
|
|||||||
+166
-28
@@ -1,14 +1,94 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
from math import pow
|
from math import pow
|
||||||
from .models import SimulationRequest, ScheduleRow, Summary, SimulationResponse, InstallmentType, OverpaymentEffect
|
|
||||||
|
from .models import (
|
||||||
|
GraceType,
|
||||||
|
HistoricalMonth,
|
||||||
|
InstallmentType,
|
||||||
|
OverpaymentEffect,
|
||||||
|
ScheduleRow,
|
||||||
|
SimulationRequest,
|
||||||
|
SimulationResponse,
|
||||||
|
Summary,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _round_money(value: float) -> float:
|
def _round_money(value: float) -> float:
|
||||||
return round(max(value, 0.0) + 1e-9, 2)
|
return round(max(value, 0.0) + 1e-9, 2)
|
||||||
|
|
||||||
|
|
||||||
def _rate_for_month(req: SimulationRequest, month: int) -> float:
|
def _add_months(base: date, months: int, day: int) -> date:
|
||||||
|
month_index = base.month - 1 + months
|
||||||
|
year = base.year + month_index // 12
|
||||||
|
month = month_index % 12 + 1
|
||||||
|
return date(year, month, min(day, 28))
|
||||||
|
|
||||||
|
|
||||||
|
def _easter(year: int) -> date:
|
||||||
|
a = year % 19
|
||||||
|
b = year // 100
|
||||||
|
c = year % 100
|
||||||
|
d = b // 4
|
||||||
|
e = b % 4
|
||||||
|
f = (b + 8) // 25
|
||||||
|
g = (b - f + 1) // 3
|
||||||
|
h = (19 * a + b - d - g + 15) % 30
|
||||||
|
i = c // 4
|
||||||
|
k = c % 4
|
||||||
|
l = (32 + 2 * e + 2 * i - h - k) % 7
|
||||||
|
m = (a + 11 * h + 22 * l) // 451
|
||||||
|
month = (h + l - 7 * m + 114) // 31
|
||||||
|
day = ((h + l - 7 * m + 114) % 31) + 1
|
||||||
|
return date(year, month, day)
|
||||||
|
|
||||||
|
|
||||||
|
def _polish_holidays(year: int) -> set[date]:
|
||||||
|
easter = _easter(year)
|
||||||
|
return {
|
||||||
|
date(year, 1, 1),
|
||||||
|
date(year, 1, 6),
|
||||||
|
easter,
|
||||||
|
easter + timedelta(days=1),
|
||||||
|
date(year, 5, 1),
|
||||||
|
date(year, 5, 3),
|
||||||
|
easter + timedelta(days=60),
|
||||||
|
date(year, 8, 15),
|
||||||
|
date(year, 11, 1),
|
||||||
|
date(year, 11, 11),
|
||||||
|
date(year, 12, 25),
|
||||||
|
date(year, 12, 26),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_business_day(value: date) -> bool:
|
||||||
|
return value.weekday() < 5 and value not in _polish_holidays(value.year)
|
||||||
|
|
||||||
|
|
||||||
|
def _move_to_business_day(value: date, enabled: bool) -> date:
|
||||||
|
if not enabled:
|
||||||
|
return value
|
||||||
|
while not _is_business_day(value):
|
||||||
|
value += timedelta(days=1)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _due_date_for_month(req: SimulationRequest, month: int) -> date:
|
||||||
|
raw = _add_months(req.loan_start_date, month, req.due_day)
|
||||||
|
return _move_to_business_day(raw, req.move_due_date_to_business_day)
|
||||||
|
|
||||||
|
|
||||||
|
def _historical_map(req: SimulationRequest) -> dict[int, HistoricalMonth]:
|
||||||
|
return {item.month: item for item in req.historical_months}
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_for_month(req: SimulationRequest, month: int, historical: dict[int, HistoricalMonth] | None = None) -> float:
|
||||||
|
historical = historical or _historical_map(req)
|
||||||
|
hist = historical.get(month)
|
||||||
|
if hist and hist.annual_rate is not None:
|
||||||
|
return hist.annual_rate
|
||||||
|
|
||||||
changes = sorted(req.rate_changes, key=lambda x: x.month)
|
changes = sorted(req.rate_changes, key=lambda x: x.month)
|
||||||
rate = req.base_rate + req.margin
|
rate = req.base_rate + req.margin
|
||||||
for change in changes:
|
for change in changes:
|
||||||
@@ -19,6 +99,11 @@ def _rate_for_month(req: SimulationRequest, month: int) -> float:
|
|||||||
return rate
|
return rate
|
||||||
|
|
||||||
|
|
||||||
|
def _grace_for_month(req: SimulationRequest, month: int, historical: dict[int, HistoricalMonth]) -> GraceType:
|
||||||
|
hist = historical.get(month)
|
||||||
|
return hist.grace_type if hist else GraceType.none
|
||||||
|
|
||||||
|
|
||||||
def _monthly_payment(balance: float, monthly_rate: float, months_left: int) -> float:
|
def _monthly_payment(balance: float, monthly_rate: float, months_left: int) -> float:
|
||||||
if months_left <= 0:
|
if months_left <= 0:
|
||||||
return balance
|
return balance
|
||||||
@@ -28,89 +113,140 @@ def _monthly_payment(balance: float, monthly_rate: float, months_left: int) -> f
|
|||||||
return balance * monthly_rate * factor / (factor - 1)
|
return balance * monthly_rate * factor / (factor - 1)
|
||||||
|
|
||||||
|
|
||||||
def _overpayment_for_month(req: SimulationRequest, month: int) -> float:
|
def _scheduled_overpayment_for_month(req: SimulationRequest, month: int) -> tuple[float, float]:
|
||||||
total = 0.0
|
total = 0.0
|
||||||
|
fee = 0.0
|
||||||
for op in req.overpayments:
|
for op in req.overpayments:
|
||||||
until = op.until_month or month
|
until = op.until_month or month
|
||||||
|
active = False
|
||||||
if op.repeat == "once" and month == op.month:
|
if op.repeat == "once" and month == op.month:
|
||||||
total += op.amount
|
active = True
|
||||||
elif op.repeat == "monthly" and month >= op.month and month <= until:
|
elif op.repeat == "monthly" and month >= op.month and month <= until:
|
||||||
total += op.amount
|
active = True
|
||||||
elif op.repeat == "yearly" and month >= op.month and month <= until and (month - op.month) % 12 == 0:
|
elif op.repeat == "yearly" and month >= op.month and month <= until and (month - op.month) % 12 == 0:
|
||||||
|
active = True
|
||||||
|
if active:
|
||||||
total += op.amount
|
total += op.amount
|
||||||
return total
|
fee += op.amount * op.commission_percent / 100
|
||||||
|
return total, fee
|
||||||
|
|
||||||
|
|
||||||
|
def _historical_overpayment_for_month(historical: dict[int, HistoricalMonth], month: int) -> tuple[float, float]:
|
||||||
|
hist = historical.get(month)
|
||||||
|
if not hist or hist.overpayment <= 0:
|
||||||
|
return 0.0, 0.0
|
||||||
|
return hist.overpayment, hist.overpayment * hist.overpayment_commission_percent / 100
|
||||||
|
|
||||||
|
|
||||||
|
def _overpayment_for_month(req: SimulationRequest, month: int, historical: dict[int, HistoricalMonth]) -> tuple[float, float]:
|
||||||
|
scheduled_amount, scheduled_fee = _scheduled_overpayment_for_month(req, month)
|
||||||
|
hist_amount, hist_fee = _historical_overpayment_for_month(historical, month)
|
||||||
|
return scheduled_amount + hist_amount, scheduled_fee + hist_fee
|
||||||
|
|
||||||
|
|
||||||
def _simulate_raw(req: SimulationRequest, include_overpayments: bool = True) -> list[ScheduleRow]:
|
def _simulate_raw(req: SimulationRequest, include_overpayments: bool = True) -> list[ScheduleRow]:
|
||||||
balance = float(req.principal)
|
balance = float(req.principal)
|
||||||
total_months = int(req.years * 12)
|
total_months = int(req.years * 12)
|
||||||
rows: list[ScheduleRow] = []
|
rows: list[ScheduleRow] = []
|
||||||
fixed_payment = None
|
fixed_payment: float | None = None
|
||||||
recalculation_month = 1
|
|
||||||
month = 1
|
month = 1
|
||||||
|
previous_due = req.loan_start_date
|
||||||
|
historical = _historical_map(req)
|
||||||
|
previous_rate: float | None = None
|
||||||
|
cumulative_interest = 0.0
|
||||||
|
cumulative_cost = 0.0
|
||||||
|
cumulative_overpayment = 0.0
|
||||||
|
|
||||||
while balance > 0.005 and month <= total_months + 600:
|
while balance > 0.005 and month <= total_months + 600:
|
||||||
months_left = max(total_months - month + 1, 1)
|
months_left = max(total_months - month + 1, 1)
|
||||||
annual_rate = _rate_for_month(req, month)
|
due_date = _due_date_for_month(req, month)
|
||||||
monthly_rate = annual_rate / 100 / 12
|
days = max((due_date - previous_due).days, 1)
|
||||||
interest = balance * monthly_rate
|
annual_rate = _rate_for_month(req, month, historical)
|
||||||
|
daily_rate = annual_rate / 100 / 365
|
||||||
|
formula_monthly_rate = annual_rate / 100 / 12
|
||||||
|
interest = balance * daily_rate * days
|
||||||
|
grace_type = _grace_for_month(req, month, historical)
|
||||||
|
|
||||||
if req.installment_type == InstallmentType.equal:
|
if req.installment_type == InstallmentType.equal:
|
||||||
if fixed_payment is None or req.overpayment_effect == OverpaymentEffect.lower_payment:
|
needs_recalc = fixed_payment is None or annual_rate != previous_rate
|
||||||
fixed_payment = _monthly_payment(balance, monthly_rate, months_left)
|
if include_overpayments and req.overpayment_effect == OverpaymentEffect.lower_payment:
|
||||||
|
needs_recalc = True
|
||||||
|
if needs_recalc:
|
||||||
|
fixed_payment = _monthly_payment(balance, formula_monthly_rate, months_left)
|
||||||
payment = fixed_payment
|
payment = fixed_payment
|
||||||
principal_part = max(payment - interest, 0.0)
|
principal_part = max(payment - interest, 0.0)
|
||||||
else:
|
else:
|
||||||
principal_base = req.principal / total_months
|
principal_base = req.principal / total_months
|
||||||
if req.overpayment_effect == OverpaymentEffect.lower_payment:
|
if req.overpayment_effect == OverpaymentEffect.lower_payment:
|
||||||
active_months_left = max(total_months - month + 1, 1)
|
principal_base = balance / months_left
|
||||||
principal_base = balance / active_months_left
|
|
||||||
payment = principal_base + interest
|
payment = principal_base + interest
|
||||||
principal_part = principal_base
|
principal_part = principal_base
|
||||||
|
|
||||||
|
if grace_type == GraceType.interest_only:
|
||||||
|
principal_part = 0.0
|
||||||
|
payment = interest
|
||||||
|
elif grace_type == GraceType.full:
|
||||||
|
principal_part = 0.0
|
||||||
|
payment = 0.0
|
||||||
|
interest = 0.0
|
||||||
|
|
||||||
if principal_part > balance:
|
if principal_part > balance:
|
||||||
principal_part = balance
|
principal_part = balance
|
||||||
payment = interest + principal_part
|
payment = interest + principal_part
|
||||||
|
|
||||||
balance -= principal_part
|
balance -= principal_part
|
||||||
|
|
||||||
overpayment = _overpayment_for_month(req, month) if include_overpayments else 0.0
|
overpayment = 0.0
|
||||||
overpayment = min(overpayment, balance)
|
overpayment_fee = 0.0
|
||||||
balance -= overpayment
|
if include_overpayments:
|
||||||
|
requested_overpayment, requested_fee = _overpayment_for_month(req, month, historical)
|
||||||
|
overpayment = min(requested_overpayment, balance)
|
||||||
|
overpayment_fee = requested_fee * (overpayment / requested_overpayment) if requested_overpayment > 0 else 0.0
|
||||||
|
balance -= overpayment
|
||||||
|
|
||||||
|
cumulative_interest += interest
|
||||||
|
cumulative_overpayment += overpayment
|
||||||
|
cumulative_cost += interest + overpayment_fee
|
||||||
|
|
||||||
rows.append(ScheduleRow(
|
rows.append(ScheduleRow(
|
||||||
month=month,
|
month=month,
|
||||||
|
due_date=due_date.isoformat(),
|
||||||
|
days=days,
|
||||||
rate=round(annual_rate, 4),
|
rate=round(annual_rate, 4),
|
||||||
payment=_round_money(payment),
|
payment=_round_money(payment),
|
||||||
principal_part=_round_money(principal_part),
|
principal_part=_round_money(principal_part),
|
||||||
interest_part=_round_money(interest),
|
interest_part=_round_money(interest),
|
||||||
overpayment=_round_money(overpayment),
|
overpayment=_round_money(overpayment),
|
||||||
|
overpayment_fee=_round_money(overpayment_fee),
|
||||||
remaining=_round_money(balance),
|
remaining=_round_money(balance),
|
||||||
|
grace_type=grace_type,
|
||||||
|
cumulative_interest=_round_money(cumulative_interest),
|
||||||
|
cumulative_cost=_round_money(cumulative_cost),
|
||||||
|
cumulative_overpayment=_round_money(cumulative_overpayment),
|
||||||
))
|
))
|
||||||
|
|
||||||
if include_overpayments and overpayment > 0 and req.overpayment_effect == OverpaymentEffect.lower_payment:
|
if include_overpayments and overpayment > 0 and req.overpayment_effect == OverpaymentEffect.lower_payment:
|
||||||
fixed_payment = None
|
fixed_payment = None
|
||||||
recalculation_month = month + 1
|
|
||||||
|
|
||||||
if include_overpayments and overpayment > 0 and req.overpayment_effect == OverpaymentEffect.shorten:
|
|
||||||
# przy skroceniu okresu rata zostaje z grubsza taka sama; petla zakonczy sie szybciej
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
previous_due = due_date
|
||||||
|
previous_rate = annual_rate
|
||||||
month += 1
|
month += 1
|
||||||
if month - recalculation_month > 1200:
|
|
||||||
break
|
|
||||||
|
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def simulate(req: SimulationRequest) -> SimulationResponse:
|
def simulate(req: SimulationRequest) -> SimulationResponse:
|
||||||
actual = _simulate_raw(req, include_overpayments=True)
|
actual = _simulate_raw(req, include_overpayments=True)
|
||||||
baseline_req = req.model_copy(update={"overpayments": []})
|
baseline_req = req.model_copy(update={"overpayments": [], "historical_months": [
|
||||||
|
h.model_copy(update={"overpayment": 0.0, "overpayment_commission_percent": 0.0})
|
||||||
|
for h in req.historical_months
|
||||||
|
]})
|
||||||
baseline = _simulate_raw(baseline_req, include_overpayments=False)
|
baseline = _simulate_raw(baseline_req, include_overpayments=False)
|
||||||
|
|
||||||
total_interest = sum(r.interest_part for r in actual)
|
total_interest = sum(r.interest_part for r in actual)
|
||||||
total_overpayment = sum(r.overpayment for r in actual)
|
total_overpayment = sum(r.overpayment for r in actual)
|
||||||
total_paid = sum(r.payment + r.overpayment for r in actual)
|
total_overpayment_fees = sum(r.overpayment_fee for r in actual)
|
||||||
|
total_paid = sum(r.payment + r.overpayment + r.overpayment_fee for r in actual)
|
||||||
baseline_interest = sum(r.interest_part for r in baseline)
|
baseline_interest = sum(r.interest_part for r in baseline)
|
||||||
payments = [r.payment for r in actual]
|
payments = [r.payment for r in actual]
|
||||||
|
|
||||||
@@ -119,11 +255,13 @@ def simulate(req: SimulationRequest) -> SimulationResponse:
|
|||||||
total_paid=_round_money(total_paid),
|
total_paid=_round_money(total_paid),
|
||||||
total_interest=_round_money(total_interest),
|
total_interest=_round_money(total_interest),
|
||||||
total_overpayment=_round_money(total_overpayment),
|
total_overpayment=_round_money(total_overpayment),
|
||||||
interest_saved=_round_money(baseline_interest - total_interest),
|
total_overpayment_fees=_round_money(total_overpayment_fees),
|
||||||
|
interest_saved=_round_money(baseline_interest - total_interest - total_overpayment_fees),
|
||||||
months_saved=max(len(baseline) - len(actual), 0),
|
months_saved=max(len(baseline) - len(actual), 0),
|
||||||
baseline_interest=_round_money(baseline_interest),
|
baseline_interest=_round_money(baseline_interest),
|
||||||
baseline_months=len(baseline),
|
baseline_months=len(baseline),
|
||||||
average_payment=_round_money(sum(payments) / len(payments)) if payments else 0,
|
average_payment=_round_money(sum(payments) / len(payments)) if payments else 0,
|
||||||
max_payment=_round_money(max(payments)) if payments else 0,
|
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, summary=summary)
|
||||||
|
|||||||
+125
-14
@@ -2,12 +2,17 @@ const $ = (id) => document.getElementById(id);
|
|||||||
const money = (v) => new Intl.NumberFormat('pl-PL', { style: 'currency', currency: 'PLN', maximumFractionDigits: 0 }).format(v || 0);
|
const money = (v) => new Intl.NumberFormat('pl-PL', { style: 'currency', currency: 'PLN', maximumFractionDigits: 0 }).format(v || 0);
|
||||||
const num = (id) => Number($(id).value || 0);
|
const num = (id) => Number($(id).value || 0);
|
||||||
|
|
||||||
let lineChart, pieChart, barChart;
|
let lineChart, pieChart, barChart, detailChart;
|
||||||
let socket;
|
let socket;
|
||||||
let debounceTimer;
|
let debounceTimer;
|
||||||
let lastRequest = null;
|
let lastRequest = null;
|
||||||
window.lastSimulationData = null;
|
window.lastSimulationData = null;
|
||||||
|
|
||||||
|
function todayIso() {
|
||||||
|
const d = new Date();
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
function setTheme(theme) {
|
function setTheme(theme) {
|
||||||
document.body.dataset.theme = theme;
|
document.body.dataset.theme = theme;
|
||||||
localStorage.setItem('mortgage-theme', theme);
|
localStorage.setItem('mortgage-theme', theme);
|
||||||
@@ -43,9 +48,22 @@ function buildRequest() {
|
|||||||
month: Number(row.querySelector('[data-field="month"]').value || 1),
|
month: Number(row.querySelector('[data-field="month"]').value || 1),
|
||||||
amount: Number(row.querySelector('[data-field="amount"]').value || 0),
|
amount: Number(row.querySelector('[data-field="amount"]').value || 0),
|
||||||
repeat: row.querySelector('[data-field="repeat"]').value,
|
repeat: row.querySelector('[data-field="repeat"]').value,
|
||||||
until_month: Number(row.querySelector('[data-field="until"]').value || 0) || null
|
until_month: Number(row.querySelector('[data-field="until"]').value || 0) || null,
|
||||||
|
commission_percent: Number(row.querySelector('[data-field="commission"]').value || 0)
|
||||||
})).filter(x => x.month > 0 && x.amount > 0);
|
})).filter(x => x.month > 0 && x.amount > 0);
|
||||||
|
|
||||||
|
const historicalMonths = [...document.querySelectorAll('#historicalMonths .dynamic-row')].map(row => {
|
||||||
|
const rateRaw = row.querySelector('[data-field="rate"]').value;
|
||||||
|
return {
|
||||||
|
month: Number(row.querySelector('[data-field="month"]').value || 1),
|
||||||
|
annual_rate: rateRaw === '' ? null : Number(rateRaw),
|
||||||
|
grace_type: row.querySelector('[data-field="grace"]').value,
|
||||||
|
overpayment: Number(row.querySelector('[data-field="overpayment"]').value || 0),
|
||||||
|
overpayment_commission_percent: Number(row.querySelector('[data-field="commission"]').value || 0),
|
||||||
|
note: row.querySelector('[data-field="note"]').value || ''
|
||||||
|
};
|
||||||
|
}).filter(x => x.month > 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
principal: num('principal'),
|
principal: num('principal'),
|
||||||
years: num('years'),
|
years: num('years'),
|
||||||
@@ -53,8 +71,12 @@ function buildRequest() {
|
|||||||
base_rate: num('baseRate'),
|
base_rate: num('baseRate'),
|
||||||
installment_type: $('installmentType').value,
|
installment_type: $('installmentType').value,
|
||||||
overpayment_effect: $('overpaymentEffect').value,
|
overpayment_effect: $('overpaymentEffect').value,
|
||||||
|
loan_start_date: $('loanStartDate').value || todayIso(),
|
||||||
|
due_day: num('dueDay') || 5,
|
||||||
|
move_due_date_to_business_day: $('moveDueDate').checked,
|
||||||
rate_changes: rateChanges,
|
rate_changes: rateChanges,
|
||||||
overpayments: overpayments
|
overpayments: overpayments,
|
||||||
|
historical_months: historicalMonths
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +90,7 @@ function recalc() {
|
|||||||
|
|
||||||
function addRateRow(month = 13, rate = 7.0) {
|
function addRateRow(month = 13, rate = 7.0) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'dynamic-row';
|
div.className = 'dynamic-row rate-row';
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div><label class="form-label">Od miesiąca</label><input data-field="month" type="number" min="1" class="form-control form-control-sm" value="${month}"></div>
|
<div><label class="form-label">Od miesiąca</label><input data-field="month" type="number" min="1" class="form-control form-control-sm" value="${month}"></div>
|
||||||
<div><label class="form-label">Oproc. roczne %</label><input data-field="rate" type="number" step="0.01" class="form-control form-control-sm" value="${rate}"></div>
|
<div><label class="form-label">Oproc. roczne %</label><input data-field="rate" type="number" step="0.01" class="form-control form-control-sm" value="${rate}"></div>
|
||||||
@@ -79,14 +101,15 @@ function addRateRow(month = 13, rate = 7.0) {
|
|||||||
recalc();
|
recalc();
|
||||||
}
|
}
|
||||||
|
|
||||||
function addOverpaymentRow(month = 12, amount = 10000, repeat = 'once', until = '') {
|
function addOverpaymentRow(month = 12, amount = 10000, repeat = 'once', until = '', commission = 0) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'dynamic-row overpay';
|
div.className = 'dynamic-row overpay';
|
||||||
div.innerHTML = `
|
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">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">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">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">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">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.querySelector('[data-field="repeat"]').value = repeat;
|
div.querySelector('[data-field="repeat"]').value = repeat;
|
||||||
div.querySelector('button').onclick = () => { div.remove(); recalc(); };
|
div.querySelector('button').onclick = () => { div.remove(); recalc(); };
|
||||||
@@ -95,21 +118,41 @@ function addOverpaymentRow(month = 12, amount = 10000, repeat = 'once', until =
|
|||||||
recalc();
|
recalc();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addHistoricalRow(month = 1, rate = '', grace = 'none', overpayment = 0, commission = 0, note = '') {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'dynamic-row historical';
|
||||||
|
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">Oproc. %</label><input data-field="rate" type="number" step="0.01" class="form-control form-control-sm" value="${rate ?? ''}" placeholder="auto"></div>
|
||||||
|
<div><label class="form-label">Karencja</label><select data-field="grace" class="form-select form-select-sm"><option value="none">brak</option><option value="interest_only">tylko odsetki</option><option value="full">pełna</option></select></div>
|
||||||
|
<div><label class="form-label">Nadpłata</label><input data-field="overpayment" type="number" min="0" class="form-control form-control-sm" value="${overpayment || 0}"></div>
|
||||||
|
<div><label class="form-label">Prow. %</label><input data-field="commission" type="number" min="0" step="0.01" class="form-control form-control-sm" value="${commission || 0}"></div>
|
||||||
|
<div><label class="form-label">Opis</label><input data-field="note" type="text" class="form-control form-control-sm" value="${String(note || '').replaceAll('"', '"')}"></div>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" type="button">Usuń</button>`;
|
||||||
|
div.querySelector('[data-field="grace"]').value = grace || 'none';
|
||||||
|
div.querySelector('button').onclick = () => { div.remove(); recalc(); };
|
||||||
|
div.querySelectorAll('input,select').forEach(x => x.addEventListener('input', recalc));
|
||||||
|
$('historicalMonths').appendChild(div);
|
||||||
|
recalc();
|
||||||
|
}
|
||||||
|
|
||||||
function render(data) {
|
function render(data) {
|
||||||
window.lastSimulationData = data;
|
window.lastSimulationData = data;
|
||||||
const s = data.summary;
|
const s = data.summary;
|
||||||
$('summaryCards').innerHTML = [
|
$('summaryCards').innerHTML = [
|
||||||
['Odsetki', money(s.total_interest)],
|
['Odsetki', money(s.total_interest)],
|
||||||
['Oszczędność', money(s.interest_saved)],
|
['Oszczędność netto', money(s.interest_saved)],
|
||||||
['Nadpłaty', money(s.total_overpayment)],
|
['Nadpłaty', money(s.total_overpayment)],
|
||||||
|
['Prowizje', money(s.total_overpayment_fees)],
|
||||||
['Okres', `${s.months} mies. / ${Math.ceil(s.months / 12)} lat`],
|
['Okres', `${s.months} mies. / ${Math.ceil(s.months / 12)} lat`],
|
||||||
|
['Data spłaty', s.payoff_date || '-'],
|
||||||
['Skrócenie', `${s.months_saved} mies.`],
|
['Skrócenie', `${s.months_saved} mies.`],
|
||||||
['Średnia rata', money(s.average_payment)]
|
['Średnia rata', money(s.average_payment)]
|
||||||
].map(([label, value]) => `<div class="col-6 col-md-4 col-xxl-2"><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('');
|
].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 w wysokości ${money(s.total_overpayment)} oszczędzasz około ${money(s.interest_saved)} na odsetkach. Kredyt kończy się po ${s.months} miesiącach zamiast ${s.baseline_months}. Łącznie płacisz ${money(s.total_paid)}, z czego odsetki to ${money(s.total_interest)}.`;
|
$('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)}.`;
|
||||||
|
|
||||||
$('scheduleTable').innerHTML = data.schedule.slice(0, 180).map(r => `<tr><td>${r.month}</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.remaining)}</td></tr>`).join('');
|
$('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('');
|
||||||
|
|
||||||
renderCharts(data);
|
renderCharts(data);
|
||||||
}
|
}
|
||||||
@@ -119,6 +162,10 @@ function renderCharts(data) {
|
|||||||
const labels = data.schedule.map(r => r.month);
|
const labels = data.schedule.map(r => r.month);
|
||||||
const balance = data.schedule.map(r => r.remaining);
|
const balance = data.schedule.map(r => r.remaining);
|
||||||
const payment = data.schedule.map(r => r.payment);
|
const payment = data.schedule.map(r => r.payment);
|
||||||
|
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 yearly = new Map();
|
||||||
data.schedule.forEach(r => {
|
data.schedule.forEach(r => {
|
||||||
const year = Math.ceil(r.month / 12);
|
const year = Math.ceil(r.month / 12);
|
||||||
@@ -135,7 +182,7 @@ function renderCharts(data) {
|
|||||||
pieChart?.destroy();
|
pieChart?.destroy();
|
||||||
pieChart = new Chart($('pieChart'), {
|
pieChart = new Chart($('pieChart'), {
|
||||||
type: 'pie',
|
type: 'pie',
|
||||||
data: { labels: ['Kapitał', 'Odsetki', 'Nadpłaty'], datasets: [{ data: [lastRequest.principal, data.summary.total_interest, data.summary.total_overpayment] }] },
|
data: { labels: ['Kapitał', 'Odsetki', 'Nadpłaty', 'Prowizje'], datasets: [{ data: [lastRequest.principal, data.summary.total_interest, data.summary.total_overpayment, data.summary.total_overpayment_fees] }] },
|
||||||
options: { responsive: true, maintainAspectRatio: false }
|
options: { responsive: true, maintainAspectRatio: false }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -145,6 +192,20 @@ function renderCharts(data) {
|
|||||||
data: { labels: [...yearly.keys()].map(x => `Rok ${x}`), datasets: [{ label: 'Odsetki', data: [...yearly.values()] }] },
|
data: { labels: [...yearly.keys()].map(x => `Rok ${x}`), datasets: [{ label: 'Odsetki', data: [...yearly.values()] }] },
|
||||||
options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true } } }
|
options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true } } }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
detailChart?.destroy();
|
||||||
|
detailChart = new Chart($('detailChart'), {
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{ type: 'bar', label: 'Kapitał w racie', data: principal, stack: 'rata', yAxisID: 'y' },
|
||||||
|
{ type: 'bar', label: 'Odsetki w racie', data: interest, stack: 'rata', yAxisID: 'y' },
|
||||||
|
{ type: 'bar', label: 'Nadpłata', data: overpayments, stack: 'rata', yAxisID: 'y' },
|
||||||
|
{ type: 'line', label: 'Koszt narastająco', data: cumulativeCost, yAxisID: 'y1' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, scales: { x: { stacked: true }, y: { stacked: true, beginAtZero: true }, y1: { position: 'right', beginAtZero: true, grid: { drawOnChartArea: false } } } }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function download(endpoint, filename) {
|
async function download(endpoint, filename) {
|
||||||
@@ -158,6 +219,51 @@ async function download(endpoint, filename) {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function exportJson() {
|
||||||
|
const blob = new Blob([JSON.stringify(buildRequest(), null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'parametry-kredytu.json';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRows() {
|
||||||
|
$('rateChanges').innerHTML = '';
|
||||||
|
$('overpayments').innerHTML = '';
|
||||||
|
$('historicalMonths').innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRequest(data) {
|
||||||
|
$('principal').value = data.principal ?? 600000;
|
||||||
|
$('years').value = data.years ?? 25;
|
||||||
|
$('margin').value = data.margin ?? 2;
|
||||||
|
$('baseRate').value = data.base_rate ?? 5.75;
|
||||||
|
$('installmentType').value = data.installment_type ?? 'equal';
|
||||||
|
$('overpaymentEffect').value = data.overpayment_effect ?? 'shorten';
|
||||||
|
$('loanStartDate').value = data.loan_start_date ?? todayIso();
|
||||||
|
$('dueDay').value = data.due_day ?? 5;
|
||||||
|
$('moveDueDate').checked = data.move_due_date_to_business_day ?? true;
|
||||||
|
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.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();
|
||||||
|
}
|
||||||
|
|
||||||
|
function importJsonFile(file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
try {
|
||||||
|
applyRequest(JSON.parse(reader.result));
|
||||||
|
} catch (e) {
|
||||||
|
alert(`Nie udało się wczytać JSON: ${e.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
async function loadNbp() {
|
async function loadNbp() {
|
||||||
const btn = $('loadNbp');
|
const btn = $('loadNbp');
|
||||||
const old = btn.textContent;
|
const old = btn.textContent;
|
||||||
@@ -176,14 +282,19 @@ async function loadNbp() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
['principal','years','baseRate','margin','installmentType','overpaymentEffect'].forEach(id => $(id).addEventListener('input', recalc));
|
['principal','years','baseRate','margin','installmentType','overpaymentEffect','loanStartDate','dueDay','moveDueDate'].forEach(id => $(id).addEventListener('input', recalc));
|
||||||
$('addRate').onclick = () => addRateRow();
|
$('addRate').onclick = () => addRateRow();
|
||||||
$('addOverpayment').onclick = () => addOverpaymentRow();
|
$('addOverpayment').onclick = () => addOverpaymentRow();
|
||||||
|
$('addHistorical').onclick = () => addHistoricalRow();
|
||||||
$('exportCsv').onclick = () => download('/api/export/csv', 'symulacja-kredytu.csv');
|
$('exportCsv').onclick = () => download('/api/export/csv', 'symulacja-kredytu.csv');
|
||||||
$('exportPdf').onclick = () => download('/api/export/pdf', 'symulacja-kredytu.pdf');
|
$('exportPdf').onclick = () => download('/api/export/pdf', 'symulacja-kredytu.pdf');
|
||||||
|
$('exportJson').onclick = exportJson;
|
||||||
|
$('importJson').onclick = () => $('jsonFile').click();
|
||||||
|
$('jsonFile').onchange = (e) => e.target.files?.[0] && importJsonFile(e.target.files[0]);
|
||||||
$('loadNbp').onclick = loadNbp;
|
$('loadNbp').onclick = loadNbp;
|
||||||
|
$('loanStartDate').value = todayIso();
|
||||||
initTheme();
|
initTheme();
|
||||||
|
|
||||||
addOverpaymentRow(24, 20000, 'once', '');
|
addOverpaymentRow(24, 20000, 'once', '', 0);
|
||||||
addOverpaymentRow(36, 500, 'monthly', 120);
|
addOverpaymentRow(36, 500, 'monthly', 120, 0);
|
||||||
connectWs();
|
connectWs();
|
||||||
|
|||||||
+36
-1
@@ -18,8 +18,11 @@
|
|||||||
<div class="d-flex gap-2 flex-wrap justify-content-end">
|
<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>
|
<button id="themeToggle" class="btn btn-outline-secondary btn-sm" type="button" aria-label="Przełącz motyw">☀️ Jasny</button>
|
||||||
<button id="loadNbp" class="btn btn-outline-primary btn-sm">Pobierz stopę NBP</button>
|
<button id="loadNbp" class="btn btn-outline-primary btn-sm">Pobierz stopę NBP</button>
|
||||||
|
<button id="exportJson" class="btn btn-outline-secondary btn-sm">Eksport JSON</button>
|
||||||
|
<button id="importJson" class="btn btn-outline-secondary btn-sm">Import JSON</button>
|
||||||
<button id="exportCsv" class="btn btn-outline-secondary btn-sm">CSV</button>
|
<button id="exportCsv" class="btn btn-outline-secondary btn-sm">CSV</button>
|
||||||
<button id="exportPdf" class="btn btn-primary btn-sm">PDF</button>
|
<button id="exportPdf" class="btn btn-primary btn-sm">PDF</button>
|
||||||
|
<input id="jsonFile" type="file" accept="application/json,.json" hidden>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -60,6 +63,20 @@
|
|||||||
<option value="lower_payment">Zmniejszenie raty</option>
|
<option value="lower_payment">Zmniejszenie raty</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">Data startu kredytu</label>
|
||||||
|
<input id="loanStartDate" type="date" class="form-control form-control-sm">
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">Dzień spłaty raty</label>
|
||||||
|
<input id="dueDay" type="number" min="1" max="28" class="form-control form-control-sm" value="5">
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-check small mb-0">
|
||||||
|
<input id="moveDueDate" class="form-check-input" type="checkbox" checked>
|
||||||
|
<span class="form-check-label">Przesuwaj spłatę na pierwszy dzień roboczy, jeśli wypada w dzień wolny</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
@@ -75,6 +92,16 @@
|
|||||||
<button id="addOverpayment" class="btn btn-sm btn-outline-primary">+</button>
|
<button id="addOverpayment" class="btn btn-sm btn-outline-primary">+</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="overpayments" class="stack"></div>
|
<div id="overpayments" class="stack"></div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<div>
|
||||||
|
<h3 class="h6 mb-0">Kredyt historyczny</h3>
|
||||||
|
<div class="text-muted small">Miesięczne oprocentowanie, karencja i faktyczne nadpłaty</div>
|
||||||
|
</div>
|
||||||
|
<button id="addHistorical" class="btn btn-sm btn-outline-primary">+</button>
|
||||||
|
</div>
|
||||||
|
<div id="historicalMonths" class="stack"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -106,6 +133,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card shadow-sm border-0 chart-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="h6">Podział raty i koszt narastająco</h2>
|
||||||
|
<canvas id="detailChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card shadow-sm border-0">
|
<div class="card shadow-sm border-0">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -119,7 +154,7 @@
|
|||||||
<div class="card-body table-responsive">
|
<div class="card-body table-responsive">
|
||||||
<h2 class="h6">Harmonogram</h2>
|
<h2 class="h6">Harmonogram</h2>
|
||||||
<table class="table table-sm align-middle mb-0">
|
<table class="table table-sm align-middle mb-0">
|
||||||
<thead><tr><th>Mies.</th><th>Oproc.</th><th>Rata</th><th>Kapitał</th><th>Odsetki</th><th>Nadpłata</th><th>Saldo</th></tr></thead>
|
<thead><tr><th>Mies.</th><th>Data</th><th>Dni</th><th>Oproc.</th><th>Rata</th><th>Kapitał</th><th>Odsetki</th><th>Nadpłata</th><th>Prow.</th><th>Koszt nar.</th><th>Saldo</th></tr></thead>
|
||||||
<tbody id="scheduleTable"></tbody>
|
<tbody id="scheduleTable"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+39
-7
@@ -27,13 +27,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body[data-theme="dark"] {
|
body[data-theme="dark"] {
|
||||||
--bg: #05070d;
|
--bg: #03050a;
|
||||||
--text: #e5e7eb;
|
--text: #e5e7eb;
|
||||||
--card: #0d1422;
|
--card: #0b1220;
|
||||||
--muted: #9aa7bb;
|
--muted: #9aa7bb;
|
||||||
--border: #10192a;
|
--border: #2a3547;
|
||||||
--row: #111a2b;
|
--row: #101827;
|
||||||
--input-bg: #070b13;
|
--input-bg: #050914;
|
||||||
--input-text: #e5e7eb;
|
--input-text: #e5e7eb;
|
||||||
--table-border: #3b4658;
|
--table-border: #3b4658;
|
||||||
}
|
}
|
||||||
@@ -100,14 +100,15 @@ body {
|
|||||||
padding: .55rem;
|
padding: .55rem;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
.dynamic-row.overpay { grid-template-columns: .75fr 1fr .9fr .75fr auto; }
|
.dynamic-row.overpay { grid-template-columns: .75fr 1fr .75fr .9fr .75fr auto; }
|
||||||
|
.dynamic-row.historical { grid-template-columns: .65fr .8fr 1fr .9fr .7fr 1.2fr auto; }
|
||||||
.stat-card { border-radius: 16px; }
|
.stat-card { border-radius: 16px; }
|
||||||
.stat-value { font-size: 1.15rem; font-weight: 700; }
|
.stat-value { font-size: 1.15rem; font-weight: 700; }
|
||||||
.table { font-size: .84rem; }
|
.table { font-size: .84rem; }
|
||||||
.btn { border-radius: 999px; }
|
.btn { border-radius: 999px; }
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.dynamic-row, .dynamic-row.overpay { grid-template-columns: 1fr 1fr; }
|
.dynamic-row, .dynamic-row.overpay, .dynamic-row.historical { grid-template-columns: 1fr 1fr; }
|
||||||
.dynamic-row button { grid-column: span 2; }
|
.dynamic-row button { grid-column: span 2; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,3 +116,34 @@ body {
|
|||||||
body[data-theme="dark"] .card { box-shadow: 0 14px 38px rgba(0, 0, 0, .34) !important; }
|
body[data-theme="dark"] .card { box-shadow: 0 14px 38px rgba(0, 0, 0, .34) !important; }
|
||||||
body[data-theme="dark"] .btn-outline-secondary { border-color: var(--border); color: var(--text); }
|
body[data-theme="dark"] .btn-outline-secondary { border-color: var(--border); color: var(--text); }
|
||||||
body[data-theme="dark"] .btn-outline-primary { border-color: #52637a; }
|
body[data-theme="dark"] .btn-outline-primary { border-color: #52637a; }
|
||||||
|
|
||||||
|
.form-check-input { background-color: var(--input-bg); border-color: var(--border); }
|
||||||
|
body[data-theme="dark"] hr { border-color: var(--border); opacity: 1; }
|
||||||
|
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.slim-card {
|
||||||
|
max-height: calc(100vh - 2rem);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slim-card > .card-body {
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.slim-card > .card-body::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slim-card > .card-body::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slim-card > .card-body::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.28);
|
||||||
|
}
|
||||||
@@ -5,3 +5,4 @@ httpx==0.28.1
|
|||||||
beautifulsoup4==4.12.3
|
beautifulsoup4==4.12.3
|
||||||
reportlab==4.2.5
|
reportlab==4.2.5
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
|
openpyxl>=3.1.5
|
||||||
Reference in New Issue
Block a user