feat(finance): Layer 1 — Property & Loan foundation (#29)
Introduces properties and loans as first-class financial entities: - models_property.go: Property, Loan, LoanView, PropertyView, PropertyData - store_property.go: full CRUD for finance_properties + finance_loans collections - handler_property.go: GET/POST /property with add/edit/delete for both entities; amortization helpers (EMI, remaining months, total interest) - templates/property.html: summary equity cards, property cards with equity bar and linked loan details, standalone loan cards with payoff progress - base.html: "Property" nav link added to desktop and mobile drawer - storeIface + mockStore updated with 10 new property/loan methods Co-authored-by: Gonçalo Rodrigues <guga@Goncalos-MacBook-Pro.local> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
05dd725579
commit
4305a77612
@ -287,6 +287,18 @@ type storeIface interface {
|
||||
createImportSchedule(ctx context.Context, sched *ImportSchedule) error
|
||||
deleteImportSchedule(ctx context.Context, id, userID string) error
|
||||
|
||||
// Property & Loan
|
||||
getProperties(ctx context.Context, userID string) ([]Property, error)
|
||||
getProperty(ctx context.Context, id, userID string) (*Property, error)
|
||||
createProperty(ctx context.Context, p *Property) error
|
||||
updateProperty(ctx context.Context, id, userID string, update bson.M) error
|
||||
deleteProperty(ctx context.Context, id, userID string) error
|
||||
getLoans(ctx context.Context, userID string) ([]Loan, error)
|
||||
getLoan(ctx context.Context, id, userID string) (*Loan, error)
|
||||
createLoan(ctx context.Context, l *Loan) error
|
||||
updateLoan(ctx context.Context, id, userID string, update bson.M) error
|
||||
deleteLoan(ctx context.Context, id, userID string) error
|
||||
|
||||
// Org
|
||||
getOrgsForUser(ctx context.Context, userID string) ([]OrgWithRole, error)
|
||||
getOrg(ctx context.Context, orgID string) (*Org, error)
|
||||
@ -2691,6 +2703,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /tax", h.Tax)
|
||||
mux.HandleFunc("GET /tax/export.csv", h.TaxExport)
|
||||
mux.HandleFunc("GET /auto-import", h.AutoImport)
|
||||
mux.HandleFunc("GET /property", h.Properties)
|
||||
mux.HandleFunc("POST /property", h.Properties)
|
||||
|
||||
h.RegisterOrgRoutes(mux)
|
||||
}
|
||||
|
||||
298
apps/finance/services/api/main/handler_property.go
Normal file
298
apps/finance/services/api/main/handler_property.go
Normal file
@ -0,0 +1,298 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
var propertyTmpl = parseTmpl("templates/property.html")
|
||||
|
||||
// ── Amortization helpers ──────────────────────────────────────────────────────
|
||||
|
||||
// loanMonthlyPayment computes the fixed EMI for a standard amortising loan.
|
||||
// annualRatePct is e.g. 3.2 for 3.2%.
|
||||
func loanMonthlyPayment(principalCents int64, annualRatePct float64, termMonths int) int64 {
|
||||
if termMonths <= 0 {
|
||||
return 0
|
||||
}
|
||||
if annualRatePct == 0 {
|
||||
return principalCents / int64(termMonths)
|
||||
}
|
||||
r := annualRatePct / 12 / 100
|
||||
factor := math.Pow(1+r, float64(termMonths))
|
||||
return int64(math.Round(float64(principalCents) * r * factor / (factor - 1)))
|
||||
}
|
||||
|
||||
// loanRemainingMonths estimates months to pay off balance at a given monthly payment.
|
||||
func loanRemainingMonths(balanceCents int64, annualRatePct float64, monthlyPaymentCents int64) int {
|
||||
if monthlyPaymentCents <= 0 || balanceCents <= 0 {
|
||||
return 0
|
||||
}
|
||||
if annualRatePct == 0 {
|
||||
return int(math.Ceil(float64(balanceCents) / float64(monthlyPaymentCents)))
|
||||
}
|
||||
r := annualRatePct / 12 / 100
|
||||
rB := r * float64(balanceCents)
|
||||
M := float64(monthlyPaymentCents)
|
||||
if rB >= M {
|
||||
return 999 // monthly payment doesn't cover interest
|
||||
}
|
||||
return int(math.Ceil(-math.Log(1-rB/M) / math.Log(1+r)))
|
||||
}
|
||||
|
||||
func toLoanView(l Loan) LoanView {
|
||||
monthly := l.MonthlyPaymentCents
|
||||
if monthly == 0 {
|
||||
monthly = loanMonthlyPayment(l.PrincipalCents, l.InterestRatePct, l.TermMonths)
|
||||
}
|
||||
remaining := loanRemainingMonths(l.BalanceCents, l.InterestRatePct, monthly)
|
||||
payoff := time.Now().AddDate(0, remaining, 0)
|
||||
|
||||
totalRemainingInterest := monthly*int64(remaining) - l.BalanceCents
|
||||
if totalRemainingInterest < 0 {
|
||||
totalRemainingInterest = 0
|
||||
}
|
||||
paid := l.PrincipalCents - l.BalanceCents
|
||||
if paid < 0 {
|
||||
paid = 0
|
||||
}
|
||||
var paidPct int64
|
||||
if l.PrincipalCents > 0 {
|
||||
paidPct = paid * 100 / l.PrincipalCents
|
||||
}
|
||||
return LoanView{
|
||||
Loan: l,
|
||||
EffectiveMonthlyPaymentCents: monthly,
|
||||
RemainingMonths: remaining,
|
||||
PayoffDate: payoff,
|
||||
TotalRemainingInterestCents: totalRemainingInterest,
|
||||
PaidSoFarCents: paid,
|
||||
PaidPct: paidPct,
|
||||
}
|
||||
}
|
||||
|
||||
func toPropertyView(p Property, allLoans []Loan) PropertyView {
|
||||
equityCents := p.CurrentValueCents
|
||||
var linked *LoanView
|
||||
for _, l := range allLoans {
|
||||
if l.PropertyID == p.ID && l.Status == LoanActive {
|
||||
v := toLoanView(l)
|
||||
linked = &v
|
||||
equityCents -= l.BalanceCents
|
||||
}
|
||||
}
|
||||
gain := p.CurrentValueCents - p.PurchasePriceCents
|
||||
gainPct := 0.0
|
||||
if p.PurchasePriceCents > 0 {
|
||||
gainPct = float64(gain) / float64(p.PurchasePriceCents) * 100
|
||||
}
|
||||
var equityPct int64
|
||||
if p.CurrentValueCents > 0 && equityCents > 0 {
|
||||
equityPct = equityCents * 100 / p.CurrentValueCents
|
||||
}
|
||||
labels := map[PropertyStatus]string{
|
||||
PropertyOwned: "Owned",
|
||||
PropertyBuilding: "Building",
|
||||
PropertySold: "Sold",
|
||||
}
|
||||
return PropertyView{
|
||||
Property: p,
|
||||
LinkedLoan: linked,
|
||||
EquityCents: equityCents,
|
||||
GainCents: gain,
|
||||
GainPct: gainPct,
|
||||
EquityPct: equityPct,
|
||||
LoanPct: 100 - equityPct,
|
||||
StatusLabel: labels[p.Status],
|
||||
}
|
||||
}
|
||||
|
||||
// ── Handler ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *Handler) Properties(w http.ResponseWriter, r *http.Request) {
|
||||
auth := h.getAuth(r)
|
||||
if auth.UserID == "" {
|
||||
http.Redirect(w, r, "/auth/login?next=/property", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.propertiesGET(w, r, auth)
|
||||
case http.MethodPost:
|
||||
h.propertiesPOST(w, r, auth)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) propertiesGET(w http.ResponseWriter, r *http.Request, auth authInfo) {
|
||||
ctx := r.Context()
|
||||
props, _ := h.store.getProperties(ctx, auth.UserID)
|
||||
loans, _ := h.store.getLoans(ctx, auth.UserID)
|
||||
|
||||
propIDs := map[string]bool{}
|
||||
var views []PropertyView
|
||||
var totalValue, totalLoan int64
|
||||
|
||||
for _, p := range props {
|
||||
propIDs[p.ID] = true
|
||||
v := toPropertyView(p, loans)
|
||||
views = append(views, v)
|
||||
if p.Status != PropertySold {
|
||||
totalValue += p.CurrentValueCents
|
||||
if v.LinkedLoan != nil {
|
||||
totalLoan += v.LinkedLoan.BalanceCents
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var unlinked []LoanView
|
||||
for _, l := range loans {
|
||||
if l.Status == LoanActive && !propIDs[l.PropertyID] {
|
||||
v := toLoanView(l)
|
||||
unlinked = append(unlinked, v)
|
||||
totalLoan += l.BalanceCents
|
||||
}
|
||||
}
|
||||
|
||||
render(w, propertyTmpl, PropertyData{
|
||||
UserID: auth.UserID,
|
||||
Email: auth.Email,
|
||||
Title: "Property",
|
||||
Route: "property",
|
||||
Properties: views,
|
||||
UnlinkedLoans: unlinked,
|
||||
TotalPropertyValueCents: totalValue,
|
||||
TotalLoanBalanceCents: totalLoan,
|
||||
TotalEquityCents: totalValue - totalLoan,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) propertiesPOST(w http.ResponseWriter, r *http.Request, auth authInfo) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
action := r.FormValue("action")
|
||||
|
||||
switch action {
|
||||
case "add_property":
|
||||
purchasePrice := parseFormCents(r.FormValue("purchase_price"))
|
||||
currentValue := parseFormCents(r.FormValue("current_value"))
|
||||
if currentValue == 0 {
|
||||
currentValue = purchasePrice
|
||||
}
|
||||
appPct, _ := strconv.ParseFloat(r.FormValue("appreciation_pct"), 64)
|
||||
purchaseDate, _ := time.Parse("2006-01-02", r.FormValue("purchase_date"))
|
||||
|
||||
p := &Property{
|
||||
ID: genID(),
|
||||
UserID: auth.UserID,
|
||||
Name: strings.TrimSpace(r.FormValue("name")),
|
||||
Address: strings.TrimSpace(r.FormValue("address")),
|
||||
PurchasePriceCents: purchasePrice,
|
||||
CurrentValueCents: currentValue,
|
||||
AppreciationPct: appPct,
|
||||
PurchaseDate: purchaseDate,
|
||||
Status: PropertyStatus(r.FormValue("status")),
|
||||
Notes: strings.TrimSpace(r.FormValue("notes")),
|
||||
}
|
||||
if p.Status == "" {
|
||||
p.Status = PropertyOwned
|
||||
}
|
||||
_ = h.store.createProperty(ctx, p)
|
||||
|
||||
case "update_property":
|
||||
id := r.FormValue("id")
|
||||
purchasePrice := parseFormCents(r.FormValue("purchase_price"))
|
||||
currentValue := parseFormCents(r.FormValue("current_value"))
|
||||
appPct, _ := strconv.ParseFloat(r.FormValue("appreciation_pct"), 64)
|
||||
purchaseDate, _ := time.Parse("2006-01-02", r.FormValue("purchase_date"))
|
||||
_ = h.store.updateProperty(ctx, id, auth.UserID, bson.M{
|
||||
"name": strings.TrimSpace(r.FormValue("name")),
|
||||
"address": strings.TrimSpace(r.FormValue("address")),
|
||||
"purchase_price_cents": purchasePrice,
|
||||
"current_value_cents": currentValue,
|
||||
"appreciation_pct": appPct,
|
||||
"purchase_date": purchaseDate,
|
||||
"status": PropertyStatus(r.FormValue("status")),
|
||||
"notes": strings.TrimSpace(r.FormValue("notes")),
|
||||
})
|
||||
|
||||
case "delete_property":
|
||||
_ = h.store.deleteProperty(ctx, r.FormValue("id"), auth.UserID)
|
||||
|
||||
case "add_loan":
|
||||
principalCents := parseFormCents(r.FormValue("principal"))
|
||||
balanceCents := parseFormCents(r.FormValue("balance"))
|
||||
if balanceCents == 0 {
|
||||
balanceCents = principalCents
|
||||
}
|
||||
ratePct, _ := strconv.ParseFloat(r.FormValue("interest_rate"), 64)
|
||||
termMonths, _ := strconv.Atoi(r.FormValue("term_months"))
|
||||
monthlyCents := parseFormCents(r.FormValue("monthly_payment"))
|
||||
startDate, _ := time.Parse("2006-01-02", r.FormValue("start_date"))
|
||||
|
||||
l := &Loan{
|
||||
ID: genID(),
|
||||
UserID: auth.UserID,
|
||||
PropertyID: r.FormValue("property_id"),
|
||||
Name: strings.TrimSpace(r.FormValue("name")),
|
||||
Type: LoanType(r.FormValue("loan_type")),
|
||||
PrincipalCents: principalCents,
|
||||
BalanceCents: balanceCents,
|
||||
InterestRatePct: ratePct,
|
||||
TermMonths: termMonths,
|
||||
StartDate: startDate,
|
||||
MonthlyPaymentCents: monthlyCents,
|
||||
Status: LoanActive,
|
||||
Notes: strings.TrimSpace(r.FormValue("notes")),
|
||||
}
|
||||
if l.Type == "" {
|
||||
l.Type = LoanMortgage
|
||||
}
|
||||
_ = h.store.createLoan(ctx, l)
|
||||
|
||||
case "update_loan_balance":
|
||||
id := r.FormValue("id")
|
||||
balanceCents := parseFormCents(r.FormValue("balance"))
|
||||
_ = h.store.updateLoan(ctx, id, auth.UserID, bson.M{
|
||||
"balance_cents": balanceCents,
|
||||
})
|
||||
|
||||
case "payoff_loan":
|
||||
_ = h.store.updateLoan(ctx, r.FormValue("id"), auth.UserID, bson.M{
|
||||
"status": LoanPaidOff,
|
||||
})
|
||||
|
||||
case "delete_loan":
|
||||
_ = h.store.deleteLoan(ctx, r.FormValue("id"), auth.UserID)
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/property", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func genID() string {
|
||||
b := make([]byte, 12)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// parseFormCents converts a plain euro amount string (e.g. "180000" or "180000.50") to cents.
|
||||
func parseFormCents(s string) int64 {
|
||||
s = strings.ReplaceAll(s, ",", ".")
|
||||
f, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return int64(math.Round(f * 100))
|
||||
}
|
||||
@ -170,6 +170,19 @@ func (m *mockStore) getImportSchedules(_ context.Context, _ string) ([]ImportSch
|
||||
func (m *mockStore) createImportSchedule(_ context.Context, _ *ImportSchedule) error { return nil }
|
||||
func (m *mockStore) deleteImportSchedule(_ context.Context, _, _ string) error { return nil }
|
||||
|
||||
// ── Property & Loan stubs ─────────────────────────────────────────────────────
|
||||
|
||||
func (m *mockStore) getProperties(_ context.Context, _ string) ([]Property, error) { return nil, nil }
|
||||
func (m *mockStore) getProperty(_ context.Context, _, _ string) (*Property, error) { return nil, nil }
|
||||
func (m *mockStore) createProperty(_ context.Context, _ *Property) error { return nil }
|
||||
func (m *mockStore) updateProperty(_ context.Context, _, _ string, _ bson.M) error { return nil }
|
||||
func (m *mockStore) deleteProperty(_ context.Context, _, _ string) error { return nil }
|
||||
func (m *mockStore) getLoans(_ context.Context, _ string) ([]Loan, error) { return nil, nil }
|
||||
func (m *mockStore) getLoan(_ context.Context, _, _ string) (*Loan, error) { return nil, nil }
|
||||
func (m *mockStore) createLoan(_ context.Context, _ *Loan) error { return nil }
|
||||
func (m *mockStore) updateLoan(_ context.Context, _, _ string, _ bson.M) error { return nil }
|
||||
func (m *mockStore) deleteLoan(_ context.Context, _, _ string) error { return nil }
|
||||
|
||||
// ── Org stubs (not exercised in unit tests) ───────────────────────────────────
|
||||
|
||||
func (m *mockStore) getOrgsForUser(_ context.Context, _ string) ([]OrgWithRole, error) {
|
||||
|
||||
94
apps/finance/services/api/main/models_property.go
Normal file
94
apps/finance/services/api/main/models_property.go
Normal file
@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
import "time"
|
||||
|
||||
type PropertyStatus string
|
||||
|
||||
const (
|
||||
PropertyOwned PropertyStatus = "owned"
|
||||
PropertyBuilding PropertyStatus = "building"
|
||||
PropertySold PropertyStatus = "sold"
|
||||
)
|
||||
|
||||
type LoanType string
|
||||
|
||||
const (
|
||||
LoanMortgage LoanType = "mortgage"
|
||||
LoanConstruction LoanType = "construction"
|
||||
LoanPersonal LoanType = "personal"
|
||||
)
|
||||
|
||||
type LoanStatus string
|
||||
|
||||
const (
|
||||
LoanActive LoanStatus = "active"
|
||||
LoanPaidOff LoanStatus = "paid_off"
|
||||
)
|
||||
|
||||
type Property struct {
|
||||
ID string `bson:"_id" json:"id"`
|
||||
UserID string `bson:"user_id" json:"user_id"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
Address string `bson:"address,omitempty" json:"address,omitempty"`
|
||||
PurchasePriceCents int64 `bson:"purchase_price_cents" json:"purchase_price_cents"`
|
||||
CurrentValueCents int64 `bson:"current_value_cents" json:"current_value_cents"`
|
||||
AppreciationPct float64 `bson:"appreciation_pct" json:"appreciation_pct"` // annual %
|
||||
PurchaseDate time.Time `bson:"purchase_date" json:"purchase_date"`
|
||||
Status PropertyStatus `bson:"status" json:"status"`
|
||||
Notes string `bson:"notes,omitempty" json:"notes,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
type Loan struct {
|
||||
ID string `bson:"_id" json:"id"`
|
||||
UserID string `bson:"user_id" json:"user_id"`
|
||||
PropertyID string `bson:"property_id,omitempty" json:"property_id,omitempty"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
Type LoanType `bson:"type" json:"type"`
|
||||
PrincipalCents int64 `bson:"principal_cents" json:"principal_cents"`
|
||||
BalanceCents int64 `bson:"balance_cents" json:"balance_cents"`
|
||||
InterestRatePct float64 `bson:"interest_rate_pct" json:"interest_rate_pct"` // annual %
|
||||
TermMonths int `bson:"term_months" json:"term_months"`
|
||||
StartDate time.Time `bson:"start_date" json:"start_date"`
|
||||
MonthlyPaymentCents int64 `bson:"monthly_payment_cents" json:"monthly_payment_cents"` // 0 = computed
|
||||
Status LoanStatus `bson:"status" json:"status"`
|
||||
Notes string `bson:"notes,omitempty" json:"notes,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
// LoanView enriches a Loan with computed amortization fields — never stored.
|
||||
type LoanView struct {
|
||||
Loan
|
||||
EffectiveMonthlyPaymentCents int64
|
||||
RemainingMonths int
|
||||
PayoffDate time.Time
|
||||
TotalRemainingInterestCents int64
|
||||
PaidSoFarCents int64
|
||||
PaidPct int64 // int64 so the "sub" template func works
|
||||
}
|
||||
|
||||
// PropertyView enriches a Property with equity and linked loan — never stored.
|
||||
type PropertyView struct {
|
||||
Property
|
||||
LinkedLoan *LoanView
|
||||
EquityCents int64
|
||||
GainCents int64
|
||||
GainPct float64
|
||||
EquityPct int64 // int64 so the "sub" template func works
|
||||
LoanPct int64 // 100 - EquityPct, pre-computed for the template
|
||||
StatusLabel string
|
||||
}
|
||||
|
||||
type PropertyData struct {
|
||||
UserID string
|
||||
Email string
|
||||
Title string
|
||||
Route string
|
||||
|
||||
Properties []PropertyView
|
||||
UnlinkedLoans []LoanView // active loans not attached to any property
|
||||
|
||||
TotalPropertyValueCents int64
|
||||
TotalLoanBalanceCents int64
|
||||
TotalEquityCents int64
|
||||
}
|
||||
103
apps/finance/services/api/main/store_property.go
Normal file
103
apps/finance/services/api/main/store_property.go
Normal file
@ -0,0 +1,103 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
func (s *Store) getProperties(ctx context.Context, userID string) ([]Property, error) {
|
||||
cur, err := s.db.Collection("finance_properties").Find(ctx,
|
||||
bson.M{"user_id": userID},
|
||||
options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []Property
|
||||
if err := cur.All(ctx, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) getProperty(ctx context.Context, id, userID string) (*Property, error) {
|
||||
var p Property
|
||||
err := s.db.Collection("finance_properties").FindOne(ctx,
|
||||
bson.M{"_id": id, "user_id": userID},
|
||||
).Decode(&p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (s *Store) createProperty(ctx context.Context, p *Property) error {
|
||||
p.CreatedAt = time.Now()
|
||||
_, err := s.db.Collection("finance_properties").InsertOne(ctx, p)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) updateProperty(ctx context.Context, id, userID string, update bson.M) error {
|
||||
_, err := s.db.Collection("finance_properties").UpdateOne(ctx,
|
||||
bson.M{"_id": id, "user_id": userID},
|
||||
bson.M{"$set": update},
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) deleteProperty(ctx context.Context, id, userID string) error {
|
||||
_, err := s.db.Collection("finance_properties").DeleteOne(ctx,
|
||||
bson.M{"_id": id, "user_id": userID},
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) getLoans(ctx context.Context, userID string) ([]Loan, error) {
|
||||
cur, err := s.db.Collection("finance_loans").Find(ctx,
|
||||
bson.M{"user_id": userID},
|
||||
options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []Loan
|
||||
if err := cur.All(ctx, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) getLoan(ctx context.Context, id, userID string) (*Loan, error) {
|
||||
var l Loan
|
||||
err := s.db.Collection("finance_loans").FindOne(ctx,
|
||||
bson.M{"_id": id, "user_id": userID},
|
||||
).Decode(&l)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
func (s *Store) createLoan(ctx context.Context, l *Loan) error {
|
||||
l.CreatedAt = time.Now()
|
||||
_, err := s.db.Collection("finance_loans").InsertOne(ctx, l)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) updateLoan(ctx context.Context, id, userID string, update bson.M) error {
|
||||
_, err := s.db.Collection("finance_loans").UpdateOne(ctx,
|
||||
bson.M{"_id": id, "user_id": userID},
|
||||
bson.M{"$set": update},
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) deleteLoan(ctx context.Context, id, userID string) error {
|
||||
_, err := s.db.Collection("finance_loans").DeleteOne(ctx,
|
||||
bson.M{"_id": id, "user_id": userID},
|
||||
)
|
||||
return err
|
||||
}
|
||||
@ -578,6 +578,7 @@
|
||||
<a href="/transactions" class="{{if eq .Route "transactions"}}active{{end}}">Transactions</a>
|
||||
<a href="/portfolio" class="{{if eq .Route "portfolio"}}active{{end}}">Portfolio</a>
|
||||
<a href="/goals" class="{{if eq .Route "goals"}}active{{end}}">Goals</a>
|
||||
<a href="/property" class="{{if eq .Route "property"}}active{{end}}">Property</a>
|
||||
|
||||
{{$analysisActive := or (eq .Route "reports") (eq .Route "projections") (eq .Route "networth") (eq .Route "simulator") (eq .Route "tax")}}
|
||||
<div class="nav-group">
|
||||
@ -622,6 +623,7 @@
|
||||
<a href="/transactions" class="{{if eq .Route "transactions"}}active{{end}}">Transactions</a>
|
||||
<a href="/portfolio" class="{{if eq .Route "portfolio"}}active{{end}}">Portfolio</a>
|
||||
<a href="/goals" class="{{if eq .Route "goals"}}active{{end}}">Goals</a>
|
||||
<a href="/property" class="{{if eq .Route "property"}}active{{end}}">Property</a>
|
||||
<a href="/people" class="{{if eq .Route "people"}}active{{end}}">People</a>
|
||||
<hr>
|
||||
<span class="nav-drawer-section-label">Analysis</span>
|
||||
|
||||
467
apps/finance/services/api/main/templates/property.html
Normal file
467
apps/finance/services/api/main/templates/property.html
Normal file
@ -0,0 +1,467 @@
|
||||
{{template "base.html" .}}
|
||||
|
||||
{{define "content"}}
|
||||
{{$d := .}}
|
||||
|
||||
<style>
|
||||
.prop-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(340px,1fr)); gap:16px; }
|
||||
.prop-card {
|
||||
background:var(--surface); border:1px solid var(--border);
|
||||
border-radius:var(--radius); padding:22px; position:relative;
|
||||
transition:border-color 0.18s;
|
||||
}
|
||||
.prop-card:hover { border-color:var(--border2); }
|
||||
.prop-badge {
|
||||
display:inline-block; font-size:11px; font-weight:600; padding:2px 8px;
|
||||
border-radius:20px; margin-bottom:12px; text-transform:uppercase; letter-spacing:.5px;
|
||||
}
|
||||
.badge-owned { background:rgba(20,184,166,.12); color:#14b8a6; border:1px solid rgba(20,184,166,.25); }
|
||||
.badge-building { background:rgba(245,158,11,.12); color:#f59e0b; border:1px solid rgba(245,158,11,.25); }
|
||||
.badge-sold { background:rgba(148,163,184,.1); color:var(--text3); border:1px solid var(--border); }
|
||||
.prop-name { font-size:17px; font-weight:700; margin:0 0 2px; color:var(--text); }
|
||||
.prop-addr { font-size:12px; color:var(--text3); margin:0 0 16px; }
|
||||
.prop-stats { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-bottom:16px; }
|
||||
.prop-stat { background:var(--surface2); border-radius:8px; padding:10px 12px; }
|
||||
.prop-stat-label { font-size:11px; color:var(--text3); margin-bottom:3px; }
|
||||
.prop-stat-value { font-size:15px; font-weight:700; color:var(--text); }
|
||||
.prop-stat-value.positive { color:var(--green); }
|
||||
.prop-stat-value.negative { color:var(--red); }
|
||||
.equity-bar-wrap { margin-bottom:16px; }
|
||||
.equity-bar-label { display:flex; justify-content:space-between; font-size:12px; color:var(--text3); margin-bottom:5px; }
|
||||
.equity-bar { height:6px; background:var(--surface2); border-radius:3px; overflow:hidden; }
|
||||
.equity-bar-fill { height:100%; border-radius:3px; background:linear-gradient(90deg,#14b8a6,#6366f1); transition:width .6s ease; }
|
||||
.loan-mini {
|
||||
background:var(--surface2); border-radius:8px; padding:12px 14px;
|
||||
font-size:12px; color:var(--text2); margin-bottom:12px;
|
||||
}
|
||||
.loan-mini-title { font-weight:600; color:var(--text); margin-bottom:6px; font-size:13px; }
|
||||
.loan-mini-row { display:flex; justify-content:space-between; margin-bottom:3px; }
|
||||
.loan-progress { height:4px; background:var(--surface); border-radius:2px; margin-top:8px; overflow:hidden; }
|
||||
.loan-progress-fill { height:100%; background:var(--accent2); border-radius:2px; }
|
||||
.prop-actions { display:flex; gap:8px; flex-wrap:wrap; }
|
||||
|
||||
.loan-card {
|
||||
background:var(--surface); border:1px solid var(--border);
|
||||
border-radius:var(--radius); padding:20px; transition:border-color .18s;
|
||||
}
|
||||
.loan-card:hover { border-color:var(--border2); }
|
||||
.loan-type-badge {
|
||||
display:inline-block; font-size:11px; font-weight:600; padding:2px 8px;
|
||||
border-radius:20px; margin-bottom:10px; text-transform:capitalize;
|
||||
background:rgba(99,102,241,.1); color:#6366f1; border:1px solid rgba(99,102,241,.2);
|
||||
}
|
||||
|
||||
/* summary cards */
|
||||
.summary-row { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; margin-bottom:28px; }
|
||||
@media(max-width:600px){ .summary-row{grid-template-columns:1fr;} .prop-grid{grid-template-columns:1fr;} }
|
||||
|
||||
/* section header */
|
||||
.section-header { display:flex; align-items:center; justify-content:space-between; margin:28px 0 14px; }
|
||||
.section-title { font-size:16px; font-weight:700; color:var(--text); }
|
||||
|
||||
/* modal */
|
||||
.modal-overlay {
|
||||
display:none; position:fixed; inset:0; z-index:1000;
|
||||
background:rgba(0,0,0,.6); backdrop-filter:blur(4px);
|
||||
align-items:center; justify-content:center; padding:16px;
|
||||
}
|
||||
.modal-box {
|
||||
background:var(--surface); border:1px solid var(--border2);
|
||||
border-radius:var(--radius); padding:28px; width:100%; max-width:480px;
|
||||
max-height:90vh; overflow-y:auto;
|
||||
}
|
||||
.modal-title { font-size:17px; font-weight:700; margin:0 0 20px; }
|
||||
.form-row { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
|
||||
@media(max-width:500px){ .form-row{grid-template-columns:1fr;} }
|
||||
.form-group { display:flex; flex-direction:column; gap:5px; margin-bottom:14px; }
|
||||
.form-group label { font-size:12px; color:var(--text3); font-weight:500; }
|
||||
.form-group input, .form-group select, .form-group textarea {
|
||||
background:var(--surface2); border:1px solid var(--border);
|
||||
border-radius:8px; padding:9px 12px; color:var(--text);
|
||||
font-size:14px; outline:none; transition:border-color .18s; width:100%; box-sizing:border-box;
|
||||
}
|
||||
.form-group input:focus, .form-group select:focus { border-color:var(--accent2); }
|
||||
.form-hint { font-size:11px; color:var(--text3); margin-top:2px; }
|
||||
.modal-footer { display:flex; gap:10px; justify-content:flex-end; margin-top:8px; }
|
||||
</style>
|
||||
|
||||
<!-- ── Summary ── -->
|
||||
<div class="summary-row">
|
||||
<div class="card value-card animate-on-scroll">
|
||||
<h2>Total property value</h2>
|
||||
<div class="value animate-counter" data-target="{{$d.TotalPropertyValueCents}}" data-prefix="€">€0</div>
|
||||
<p style="font-size:12px;color:var(--text3);margin-top:4px;">current estimated value</p>
|
||||
</div>
|
||||
<div class="card value-card animate-on-scroll">
|
||||
<h2>Outstanding loans</h2>
|
||||
<div class="value animate-counter" data-target="{{$d.TotalLoanBalanceCents}}" data-prefix="€" style="color:var(--red);">€0</div>
|
||||
<p style="font-size:12px;color:var(--text3);margin-top:4px;">remaining balance</p>
|
||||
</div>
|
||||
<div class="card value-card animate-on-scroll">
|
||||
<h2>Net equity</h2>
|
||||
<div class="value animate-counter" data-target="{{$d.TotalEquityCents}}" data-prefix="€"
|
||||
style="color:{{if ge $d.TotalEquityCents 0}}var(--green){{else}}var(--red){{end}};">€0</div>
|
||||
<p style="font-size:12px;color:var(--text3);margin-top:4px;">value − loans</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Properties ── -->
|
||||
<div class="section-header">
|
||||
<span class="section-title">Properties</span>
|
||||
<button class="btn btn-primary btn-sm" onclick="openModal('add-property-modal')">+ Add property</button>
|
||||
</div>
|
||||
|
||||
{{if $d.Properties}}
|
||||
<div class="prop-grid">
|
||||
{{range $d.Properties}}
|
||||
<div class="prop-card animate-on-scroll">
|
||||
<span class="prop-badge badge-{{.Status}}">{{.StatusLabel}}</span>
|
||||
<p class="prop-name">{{.Name}}</p>
|
||||
{{if .Address}}<p class="prop-addr">📍 {{.Address}}</p>{{end}}
|
||||
|
||||
<div class="prop-stats">
|
||||
<div class="prop-stat">
|
||||
<div class="prop-stat-label">Current value</div>
|
||||
<div class="prop-stat-value">€{{cents .CurrentValueCents}}</div>
|
||||
</div>
|
||||
<div class="prop-stat">
|
||||
<div class="prop-stat-label">Equity</div>
|
||||
<div class="prop-stat-value {{if ge .EquityCents 0}}positive{{else}}negative{{end}}">
|
||||
€{{cents .EquityCents}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="prop-stat">
|
||||
<div class="prop-stat-label">Purchase price</div>
|
||||
<div class="prop-stat-value">€{{cents .PurchasePriceCents}}</div>
|
||||
</div>
|
||||
<div class="prop-stat">
|
||||
<div class="prop-stat-label">Gain</div>
|
||||
<div class="prop-stat-value {{if ge .GainCents 0}}positive{{else}}negative{{end}}">
|
||||
{{printf "%.1f" .GainPct}}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .LinkedLoan}}
|
||||
<div class="equity-bar-wrap">
|
||||
<div class="equity-bar-label">
|
||||
<span>Equity {{.EquityPct}}%</span>
|
||||
<span>Loan {{.LoanPct}}%</span>
|
||||
</div>
|
||||
<div class="equity-bar">
|
||||
<div class="equity-bar-fill" style="width:{{.EquityPct}}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="loan-mini">
|
||||
<div class="loan-mini-title">🏦 {{.LinkedLoan.Name}}</div>
|
||||
<div class="loan-mini-row"><span>Remaining</span><span style="font-weight:600;color:var(--red);">€{{cents .LinkedLoan.BalanceCents}}</span></div>
|
||||
<div class="loan-mini-row"><span>Monthly payment</span><span>€{{cents .LinkedLoan.EffectiveMonthlyPaymentCents}}</span></div>
|
||||
<div class="loan-mini-row"><span>Payoff</span><span>{{.LinkedLoan.PayoffDate.Format "Jan 2006"}}</span></div>
|
||||
<div class="loan-mini-row"><span>Rate</span><span>{{printf "%.2f" .LinkedLoan.InterestRatePct}}%</span></div>
|
||||
<div class="loan-progress">
|
||||
<div class="loan-progress-fill" style="width:{{.LinkedLoan.PaidPct}}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="prop-actions">
|
||||
<button class="btn btn-outline btn-sm"
|
||||
onclick="openEditProperty('{{.ID}}','{{.Name}}','{{.Address}}',{{.PurchasePriceCents}},{{.CurrentValueCents}},{{printf "%.2f" .AppreciationPct}},'{{.PurchaseDate.Format "2006-01-02"}}','{{.Status}}','{{.Notes}}')">
|
||||
Edit
|
||||
</button>
|
||||
<form method="POST" action="/property" style="display:inline;">
|
||||
<input type="hidden" name="action" value="delete_property">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button type="submit" class="btn btn-outline btn-sm" style="color:var(--red);border-color:var(--red)33;"
|
||||
onclick="return confirm('Remove {{.Name}}?')">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="card empty-state animate-on-scroll">
|
||||
<div style="font-size:48px;margin-bottom:16px;">🏠</div>
|
||||
<h3>No properties yet</h3>
|
||||
<p style="margin-bottom:20px;">Add your home, investment property, or land to track equity and loans in one place.</p>
|
||||
<button class="btn btn-primary" onclick="openModal('add-property-modal')">Add your first property</button>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- ── Standalone loans ── -->
|
||||
{{if or $d.UnlinkedLoans (not $d.Properties)}}
|
||||
<div class="section-header" style="margin-top:32px;">
|
||||
<span class="section-title">Loans</span>
|
||||
<button class="btn btn-outline btn-sm" onclick="openModal('add-loan-modal')">+ Add loan</button>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if $d.UnlinkedLoans}}
|
||||
<div class="prop-grid">
|
||||
{{range $d.UnlinkedLoans}}
|
||||
<div class="loan-card animate-on-scroll">
|
||||
<span class="loan-type-badge">{{.Type}}</span>
|
||||
<p class="prop-name" style="margin-bottom:4px;">{{.Name}}</p>
|
||||
|
||||
<div class="prop-stats" style="margin-top:12px;">
|
||||
<div class="prop-stat">
|
||||
<div class="prop-stat-label">Balance</div>
|
||||
<div class="prop-stat-value negative">€{{cents .BalanceCents}}</div>
|
||||
</div>
|
||||
<div class="prop-stat">
|
||||
<div class="prop-stat-label">Monthly</div>
|
||||
<div class="prop-stat-value">€{{cents .EffectiveMonthlyPaymentCents}}</div>
|
||||
</div>
|
||||
<div class="prop-stat">
|
||||
<div class="prop-stat-label">Payoff</div>
|
||||
<div class="prop-stat-value">{{.PayoffDate.Format "Jan 2006"}}</div>
|
||||
</div>
|
||||
<div class="prop-stat">
|
||||
<div class="prop-stat-label">Rate</div>
|
||||
<div class="prop-stat-value">{{printf "%.2f" .InterestRatePct}}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="equity-bar-wrap">
|
||||
<div class="equity-bar-label">
|
||||
<span>Paid {{.PaidPct}}%</span>
|
||||
<span>€{{cents .TotalRemainingInterestCents}} interest left</span>
|
||||
</div>
|
||||
<div class="equity-bar">
|
||||
<div class="equity-bar-fill" style="width:{{.PaidPct}}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="prop-actions">
|
||||
<form method="POST" action="/property" style="display:inline;">
|
||||
<input type="hidden" name="action" value="payoff_loan">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button type="submit" class="btn btn-outline btn-sm" style="color:var(--green);border-color:var(--green)33;"
|
||||
onclick="return confirm('Mark as paid off?')">Mark paid off</button>
|
||||
</form>
|
||||
<form method="POST" action="/property" style="display:inline;">
|
||||
<input type="hidden" name="action" value="delete_loan">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button type="submit" class="btn btn-outline btn-sm" style="color:var(--red);border-color:var(--red)33;"
|
||||
onclick="return confirm('Remove {{.Name}}?')">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if $d.Properties}}
|
||||
<div style="margin-top:24px;">
|
||||
<button class="btn btn-outline btn-sm" onclick="openModal('add-loan-modal')">+ Add loan</button>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- ── Add Property Modal ── -->
|
||||
<div id="add-property-modal" class="modal-overlay" onclick="if(event.target===this)closeModal('add-property-modal')">
|
||||
<div class="modal-box">
|
||||
<p class="modal-title">Add property</p>
|
||||
<form method="POST" action="/property">
|
||||
<input type="hidden" name="action" value="add_property">
|
||||
<div class="form-group">
|
||||
<label>Property name *</label>
|
||||
<input type="text" name="name" placeholder="e.g. Main House" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Address</label>
|
||||
<input type="text" name="address" placeholder="Street, city">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Purchase price (€) *</label>
|
||||
<input type="number" name="purchase_price" placeholder="220000" step="0.01" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Current value (€)</label>
|
||||
<input type="number" name="current_value" placeholder="Same as purchase" step="0.01">
|
||||
<span class="form-hint">Leave blank to use purchase price</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Purchase date</label>
|
||||
<input type="date" name="purchase_date">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Est. appreciation (%/year)</label>
|
||||
<input type="number" name="appreciation_pct" placeholder="2.0" step="0.1" value="2.0">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Status</label>
|
||||
<select name="status">
|
||||
<option value="owned">Owned</option>
|
||||
<option value="building">Under construction</option>
|
||||
<option value="sold">Sold</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Notes</label>
|
||||
<textarea name="notes" rows="2" placeholder="Optional notes"></textarea>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline" onclick="closeModal('add-property-modal')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Add property</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Edit Property Modal ── -->
|
||||
<div id="edit-property-modal" class="modal-overlay" onclick="if(event.target===this)closeModal('edit-property-modal')">
|
||||
<div class="modal-box">
|
||||
<p class="modal-title">Edit property</p>
|
||||
<form method="POST" action="/property" id="edit-property-form">
|
||||
<input type="hidden" name="action" value="update_property">
|
||||
<input type="hidden" name="id" id="edit-prop-id">
|
||||
<div class="form-group">
|
||||
<label>Property name *</label>
|
||||
<input type="text" name="name" id="edit-prop-name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Address</label>
|
||||
<input type="text" name="address" id="edit-prop-addr">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Purchase price (€)</label>
|
||||
<input type="number" name="purchase_price" id="edit-prop-purchase" step="0.01">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Current value (€)</label>
|
||||
<input type="number" name="current_value" id="edit-prop-value" step="0.01">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Purchase date</label>
|
||||
<input type="date" name="purchase_date" id="edit-prop-date">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Appreciation (%/year)</label>
|
||||
<input type="number" name="appreciation_pct" id="edit-prop-appr" step="0.1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Status</label>
|
||||
<select name="status" id="edit-prop-status">
|
||||
<option value="owned">Owned</option>
|
||||
<option value="building">Under construction</option>
|
||||
<option value="sold">Sold</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Notes</label>
|
||||
<textarea name="notes" id="edit-prop-notes" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline" onclick="closeModal('edit-property-modal')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Add Loan Modal ── -->
|
||||
<div id="add-loan-modal" class="modal-overlay" onclick="if(event.target===this)closeModal('add-loan-modal')">
|
||||
<div class="modal-box">
|
||||
<p class="modal-title">Add loan</p>
|
||||
<form method="POST" action="/property">
|
||||
<input type="hidden" name="action" value="add_loan">
|
||||
<div class="form-group">
|
||||
<label>Loan name *</label>
|
||||
<input type="text" name="name" placeholder="e.g. Home mortgage" required>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Type</label>
|
||||
<select name="loan_type">
|
||||
<option value="mortgage">Mortgage</option>
|
||||
<option value="construction">Construction loan</option>
|
||||
<option value="personal">Personal loan</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Linked property</label>
|
||||
<select name="property_id">
|
||||
<option value="">— none —</option>
|
||||
{{range $d.Properties}}
|
||||
<option value="{{.ID}}">{{.Name}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Original principal (€) *</label>
|
||||
<input type="number" name="principal" placeholder="200000" step="0.01" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Current balance (€)</label>
|
||||
<input type="number" name="balance" placeholder="Same as principal" step="0.01">
|
||||
<span class="form-hint">Leave blank if just starting</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Interest rate (%/year) *</label>
|
||||
<input type="number" name="interest_rate" placeholder="3.2" step="0.01" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Term (months) *</label>
|
||||
<input type="number" name="term_months" placeholder="360" required>
|
||||
<span class="form-hint">e.g. 360 = 30 years</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Monthly payment (€)</label>
|
||||
<input type="number" name="monthly_payment" placeholder="Auto-computed" step="0.01">
|
||||
<span class="form-hint">Leave blank to calculate</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Start date</label>
|
||||
<input type="date" name="start_date">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Notes</label>
|
||||
<textarea name="notes" rows="2" placeholder="Bank name, reference, etc."></textarea>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline" onclick="closeModal('add-loan-modal')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Add loan</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function openModal(id) { document.getElementById(id).style.display = 'flex'; }
|
||||
function closeModal(id) { document.getElementById(id).style.display = 'none'; }
|
||||
|
||||
function openEditProperty(id, name, addr, purchaseCents, valueCents, appr, date, status, notes) {
|
||||
document.getElementById('edit-prop-id').value = id;
|
||||
document.getElementById('edit-prop-name').value = name;
|
||||
document.getElementById('edit-prop-addr').value = addr;
|
||||
document.getElementById('edit-prop-purchase').value = (purchaseCents / 100).toFixed(2);
|
||||
document.getElementById('edit-prop-value').value = (valueCents / 100).toFixed(2);
|
||||
document.getElementById('edit-prop-appr').value = appr;
|
||||
document.getElementById('edit-prop-date').value = date;
|
||||
document.getElementById('edit-prop-status').value = status;
|
||||
document.getElementById('edit-prop-notes').value = notes;
|
||||
openModal('edit-property-modal');
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') {
|
||||
['add-property-modal','edit-property-modal','add-loan-modal'].forEach(closeModal);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
Loading…
x
Reference in New Issue
Block a user