feat(finance): i18n — TOML-based translations for all personal finance templates
Adds a full translation layer (English + European Portuguese) using
BurntSushi/toml with go:embed. Locale detection reads the lang cookie,
falls back to Accept-Language, then defaults to "en". A language switcher
in the nav writes the cookie and redirects back. All 20 personal finance
templates now use {{.T.Get "key"}} for every UI string.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b4b7a1381c
commit
4b7c01e632
@ -377,6 +377,11 @@ func NewHandler(store *Store, secret, googleID, googleSecret, baseURL string) *H
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// t returns a Translator for the current request's preferred language.
|
||||||
|
func (h *Handler) t(r *http.Request) *Translator {
|
||||||
|
return newT(detectLang(r))
|
||||||
|
}
|
||||||
|
|
||||||
// securityHeaders adds defence-in-depth HTTP headers to every response.
|
// securityHeaders adds defence-in-depth HTTP headers to every response.
|
||||||
func (h *Handler) securityHeaders(next http.Handler) http.Handler {
|
func (h *Handler) securityHeaders(next http.Handler) http.Handler {
|
||||||
csp := strings.Join([]string{
|
csp := strings.Join([]string{
|
||||||
@ -435,6 +440,7 @@ func (h *Handler) ownerOrViewerMW(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
render(w, baseTmpl, map[string]interface{}{
|
render(w, baseTmpl, map[string]interface{}{
|
||||||
|
"T": h.t(r),
|
||||||
"UserID": a.UserID,
|
"UserID": a.UserID,
|
||||||
"Email": a.Email,
|
"Email": a.Email,
|
||||||
"Title": "Access Denied",
|
"Title": "Access Denied",
|
||||||
@ -463,7 +469,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
txns, err := h.store.getTransactions(ctx, a.UserID, bson.M{})
|
txns, err := h.store.getTransactions(ctx, a.UserID, bson.M{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("get transactions", "err", err)
|
slog.Error("get transactions", "err", err)
|
||||||
render(w, dashboardTmpl, &DashboardData{UserID: a.UserID, Email: a.Email, Title: "Dashboard", Route: "dashboard", IsOwner: true})
|
render(w, dashboardTmpl, &DashboardData{T: h.t(r), UserID: a.UserID, Email: a.Email, Title: "Dashboard", Route: "dashboard", IsOwner: true})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -819,6 +825,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render(w, dashboardTmpl, &DashboardData{
|
render(w, dashboardTmpl, &DashboardData{
|
||||||
|
T: h.t(r),
|
||||||
UserID: a.UserID,
|
UserID: a.UserID,
|
||||||
Email: a.Email,
|
Email: a.Email,
|
||||||
Title: "Dashboard",
|
Title: "Dashboard",
|
||||||
@ -906,6 +913,7 @@ func (h *Handler) Transactions(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render(w, txnsTmpl, map[string]interface{}{
|
render(w, txnsTmpl, map[string]interface{}{
|
||||||
|
"T": h.t(r),
|
||||||
"UserID": a.UserID,
|
"UserID": a.UserID,
|
||||||
"Email": a.Email,
|
"Email": a.Email,
|
||||||
"Title": "Transactions",
|
"Title": "Transactions",
|
||||||
@ -970,6 +978,7 @@ func (h *Handler) ImportPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
a := h.getAuth(r)
|
a := h.getAuth(r)
|
||||||
accounts, _ := h.store.getAccounts(r.Context(), a.UserID)
|
accounts, _ := h.store.getAccounts(r.Context(), a.UserID)
|
||||||
render(w, importTmpl, map[string]interface{}{
|
render(w, importTmpl, map[string]interface{}{
|
||||||
|
"T": h.t(r),
|
||||||
"UserID": a.UserID,
|
"UserID": a.UserID,
|
||||||
"Email": a.Email,
|
"Email": a.Email,
|
||||||
"Title": "Import",
|
"Title": "Import",
|
||||||
@ -1024,6 +1033,7 @@ func (h *Handler) ImportPreview(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
accounts, _ := h.store.getAccounts(ctx, a.UserID)
|
accounts, _ := h.store.getAccounts(ctx, a.UserID)
|
||||||
render(w, importTmpl, map[string]interface{}{
|
render(w, importTmpl, map[string]interface{}{
|
||||||
|
"T": h.t(r),
|
||||||
"UserID": a.UserID,
|
"UserID": a.UserID,
|
||||||
"Email": a.Email,
|
"Email": a.Email,
|
||||||
"Title": "Import",
|
"Title": "Import",
|
||||||
@ -1086,6 +1096,7 @@ func (h *Handler) ImportPreview(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render(w, importTmpl, map[string]interface{}{
|
render(w, importTmpl, map[string]interface{}{
|
||||||
|
"T": h.t(r),
|
||||||
"UserID": a.UserID,
|
"UserID": a.UserID,
|
||||||
"Email": a.Email,
|
"Email": a.Email,
|
||||||
"Title": "Import",
|
"Title": "Import",
|
||||||
@ -1302,6 +1313,7 @@ func (h *Handler) Accounts(w http.ResponseWriter, r *http.Request) {
|
|||||||
slog.Error("get accounts", "err", err)
|
slog.Error("get accounts", "err", err)
|
||||||
}
|
}
|
||||||
render(w, accountsTmpl, map[string]interface{}{
|
render(w, accountsTmpl, map[string]interface{}{
|
||||||
|
"T": h.t(r),
|
||||||
"UserID": a.UserID,
|
"UserID": a.UserID,
|
||||||
"Email": a.Email,
|
"Email": a.Email,
|
||||||
"Title": "Accounts",
|
"Title": "Accounts",
|
||||||
@ -1351,6 +1363,7 @@ func (h *Handler) Categories(w http.ResponseWriter, r *http.Request) {
|
|||||||
slog.Error("get categories", "err", err)
|
slog.Error("get categories", "err", err)
|
||||||
}
|
}
|
||||||
render(w, categoriesTmpl, map[string]interface{}{
|
render(w, categoriesTmpl, map[string]interface{}{
|
||||||
|
"T": h.t(r),
|
||||||
"UserID": a.UserID,
|
"UserID": a.UserID,
|
||||||
"Email": a.Email,
|
"Email": a.Email,
|
||||||
"Title": "Categories",
|
"Title": "Categories",
|
||||||
@ -1459,6 +1472,7 @@ func (h *Handler) Reports(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render(w, reportsTmpl, map[string]interface{}{
|
render(w, reportsTmpl, map[string]interface{}{
|
||||||
|
"T": h.t(r),
|
||||||
"UserID": a.UserID,
|
"UserID": a.UserID,
|
||||||
"Email": a.Email,
|
"Email": a.Email,
|
||||||
"Title": "Reports",
|
"Title": "Reports",
|
||||||
@ -1559,6 +1573,7 @@ func (h *Handler) Projections(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
render(w, projectionsTmpl, map[string]interface{}{
|
render(w, projectionsTmpl, map[string]interface{}{
|
||||||
|
"T": h.t(r),
|
||||||
"UserID": a.UserID,
|
"UserID": a.UserID,
|
||||||
"Email": a.Email,
|
"Email": a.Email,
|
||||||
"Title": "Projections",
|
"Title": "Projections",
|
||||||
@ -1584,6 +1599,7 @@ func (h *Handler) Portfolio(w http.ResponseWriter, r *http.Request) {
|
|||||||
isins := uniqueISINs(trades)
|
isins := uniqueISINs(trades)
|
||||||
if len(isins) == 0 {
|
if len(isins) == 0 {
|
||||||
render(w, portfolioTmpl, &PortfolioData{
|
render(w, portfolioTmpl, &PortfolioData{
|
||||||
|
T: h.t(r),
|
||||||
UserID: a.UserID,
|
UserID: a.UserID,
|
||||||
Email: a.Email,
|
Email: a.Email,
|
||||||
Title: "Portfolio",
|
Title: "Portfolio",
|
||||||
@ -1616,6 +1632,7 @@ func (h *Handler) Portfolio(w http.ResponseWriter, r *http.Request) {
|
|||||||
pr := aggregatePortfolio(holdings)
|
pr := aggregatePortfolio(holdings)
|
||||||
|
|
||||||
render(w, portfolioTmpl, &PortfolioData{
|
render(w, portfolioTmpl, &PortfolioData{
|
||||||
|
T: h.t(r),
|
||||||
UserID: a.UserID,
|
UserID: a.UserID,
|
||||||
Email: a.Email,
|
Email: a.Email,
|
||||||
Title: "Portfolio",
|
Title: "Portfolio",
|
||||||
@ -1733,6 +1750,7 @@ func (h *Handler) Sharing(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render(w, sharingTmpl, map[string]interface{}{
|
render(w, sharingTmpl, map[string]interface{}{
|
||||||
|
"T": h.t(r),
|
||||||
"UserID": a.UserID,
|
"UserID": a.UserID,
|
||||||
"Email": a.Email,
|
"Email": a.Email,
|
||||||
"Title": "Sharing",
|
"Title": "Sharing",
|
||||||
@ -1986,6 +2004,7 @@ func (h *Handler) Goals(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data := &GoalsData{
|
data := &GoalsData{
|
||||||
|
T: h.t(r),
|
||||||
UserID: a.UserID,
|
UserID: a.UserID,
|
||||||
Email: a.Email,
|
Email: a.Email,
|
||||||
Title: "Goals",
|
Title: "Goals",
|
||||||
@ -2182,6 +2201,7 @@ func (h *Handler) Simulator(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render(w, simulatorTmpl, &SimulatorData{
|
render(w, simulatorTmpl, &SimulatorData{
|
||||||
|
T: h.t(r),
|
||||||
UserID: a.UserID,
|
UserID: a.UserID,
|
||||||
Email: a.Email,
|
Email: a.Email,
|
||||||
Title: "What If",
|
Title: "What If",
|
||||||
@ -2310,6 +2330,7 @@ func (h *Handler) NetWorth(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render(w, networthTmpl, &NetWorthData{
|
render(w, networthTmpl, &NetWorthData{
|
||||||
|
T: h.t(r),
|
||||||
UserID: a.UserID,
|
UserID: a.UserID,
|
||||||
Email: a.Email,
|
Email: a.Email,
|
||||||
Title: "Net Worth",
|
Title: "Net Worth",
|
||||||
@ -2491,6 +2512,7 @@ func (h *Handler) Tax(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render(w, taxTmpl, &TaxData{
|
render(w, taxTmpl, &TaxData{
|
||||||
|
T: h.t(r),
|
||||||
UserID: auth.UserID,
|
UserID: auth.UserID,
|
||||||
Email: auth.Email,
|
Email: auth.Email,
|
||||||
Title: "Tax Summary",
|
Title: "Tax Summary",
|
||||||
@ -2549,6 +2571,7 @@ func (h *Handler) Household(w http.ResponseWriter, r *http.Request) {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
data := &HouseholdData{
|
data := &HouseholdData{
|
||||||
|
T: h.t(r),
|
||||||
UserID: auth.UserID,
|
UserID: auth.UserID,
|
||||||
Email: auth.Email,
|
Email: auth.Email,
|
||||||
Title: "Household",
|
Title: "Household",
|
||||||
@ -2644,6 +2667,7 @@ func (h *Handler) AutoImport(w http.ResponseWriter, r *http.Request) {
|
|||||||
auth := h.getAuth(r)
|
auth := h.getAuth(r)
|
||||||
accounts, _ := h.store.getAccounts(r.Context(), auth.UserID)
|
accounts, _ := h.store.getAccounts(r.Context(), auth.UserID)
|
||||||
render(w, autoImportTmpl, &AutoImportData{
|
render(w, autoImportTmpl, &AutoImportData{
|
||||||
|
T: h.t(r),
|
||||||
UserID: auth.UserID,
|
UserID: auth.UserID,
|
||||||
Email: auth.Email,
|
Email: auth.Email,
|
||||||
Title: "Import Guide",
|
Title: "Import Guide",
|
||||||
@ -2715,6 +2739,7 @@ func (h *Handler) People(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data := &PeopleData{
|
data := &PeopleData{
|
||||||
|
T: h.t(r),
|
||||||
UserID: a.UserID,
|
UserID: a.UserID,
|
||||||
Email: a.Email,
|
Email: a.Email,
|
||||||
Title: "People",
|
Title: "People",
|
||||||
@ -2795,6 +2820,7 @@ func (h *Handler) Settings(w http.ResponseWriter, r *http.Request) {
|
|||||||
categories, _ := h.store.getCategories(ctx, a.UserID)
|
categories, _ := h.store.getCategories(ctx, a.UserID)
|
||||||
|
|
||||||
render(w, settingsTmpl, &SettingsData{
|
render(w, settingsTmpl, &SettingsData{
|
||||||
|
T: h.t(r),
|
||||||
UserID: a.UserID,
|
UserID: a.UserID,
|
||||||
Email: a.Email,
|
Email: a.Email,
|
||||||
Title: "Settings",
|
Title: "Settings",
|
||||||
@ -2805,6 +2831,18 @@ func (h *Handler) Settings(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) SetLang(w http.ResponseWriter, r *http.Request) {
|
||||||
|
lang := r.FormValue("lang")
|
||||||
|
if supportedLangs[lang] {
|
||||||
|
setLangCookie(w, lang, h.isSecure())
|
||||||
|
}
|
||||||
|
back := r.Header.Get("Referer")
|
||||||
|
if back == "" {
|
||||||
|
back = "/dashboard"
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, back, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
// Auth (no authMW — these are public by definition)
|
// Auth (no authMW — these are public by definition)
|
||||||
mux.HandleFunc("GET /auth/login", h.AuthLogin)
|
mux.HandleFunc("GET /auth/login", h.AuthLogin)
|
||||||
@ -2864,6 +2902,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("GET /property", h.Properties)
|
mux.HandleFunc("GET /property", h.Properties)
|
||||||
mux.HandleFunc("POST /property", h.Properties)
|
mux.HandleFunc("POST /property", h.Properties)
|
||||||
mux.HandleFunc("GET /plan", h.Dream)
|
mux.HandleFunc("GET /plan", h.Dream)
|
||||||
|
mux.HandleFunc("POST /lang", h.SetLang)
|
||||||
|
|
||||||
h.RegisterOrgRoutes(mux)
|
h.RegisterOrgRoutes(mux)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -184,6 +184,7 @@ func (h *Handler) propertiesGET(w http.ResponseWriter, r *http.Request, auth aut
|
|||||||
}
|
}
|
||||||
|
|
||||||
render(w, propertyTmpl, PropertyData{
|
render(w, propertyTmpl, PropertyData{
|
||||||
|
T: h.t(r),
|
||||||
UserID: auth.UserID,
|
UserID: auth.UserID,
|
||||||
Email: auth.Email,
|
Email: auth.Email,
|
||||||
Title: "Property",
|
Title: "Property",
|
||||||
|
|||||||
120
apps/finance/services/api/main/i18n.go
Normal file
120
apps/finance/services/api/main/i18n.go
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/BurntSushi/toml"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed locales/*.toml
|
||||||
|
var localeFS embed.FS
|
||||||
|
|
||||||
|
const defaultLang = "en"
|
||||||
|
|
||||||
|
var supportedLangs = map[string]bool{"en": true, "pt": true}
|
||||||
|
|
||||||
|
// catalogue holds the flattened key→string map for one language.
|
||||||
|
type catalogue map[string]string
|
||||||
|
|
||||||
|
var catalogues = map[string]catalogue{}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
for lang := range supportedLangs {
|
||||||
|
cat, err := loadCatalogue(lang)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("i18n: failed to load locale", "lang", lang, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
catalogues[lang] = cat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadCatalogue(lang string) (catalogue, error) {
|
||||||
|
data, err := localeFS.ReadFile("locales/" + lang + ".toml")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var raw map[string]any
|
||||||
|
if err := toml.Unmarshal(data, &raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cat := make(catalogue)
|
||||||
|
flattenTOML("", raw, cat)
|
||||||
|
return cat, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// flattenTOML recursively flattens nested TOML tables into dot-notation keys.
|
||||||
|
func flattenTOML(prefix string, node map[string]any, out catalogue) {
|
||||||
|
for k, v := range node {
|
||||||
|
key := k
|
||||||
|
if prefix != "" {
|
||||||
|
key = prefix + "." + k
|
||||||
|
}
|
||||||
|
switch val := v.(type) {
|
||||||
|
case string:
|
||||||
|
out[key] = val
|
||||||
|
case map[string]any:
|
||||||
|
flattenTOML(key, val, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translator wraps a locale lookup and exposes a Get method callable from
|
||||||
|
// Go templates as {{.T.Get "key"}}.
|
||||||
|
type Translator struct {
|
||||||
|
cat catalogue
|
||||||
|
en catalogue
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *Translator) Get(key string) string {
|
||||||
|
if s, ok := tr.cat[key]; ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if s, ok := tr.en[key]; ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
// newT returns a Translator for the given language.
|
||||||
|
func newT(lang string) *Translator {
|
||||||
|
cat, ok := catalogues[lang]
|
||||||
|
if !ok {
|
||||||
|
cat = catalogues[defaultLang]
|
||||||
|
}
|
||||||
|
return &Translator{cat: cat, en: catalogues[defaultLang]}
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectLang reads the lang from a cookie, falling back to Accept-Language,
|
||||||
|
// then to the default. Only returns a supported language code.
|
||||||
|
func detectLang(r *http.Request) string {
|
||||||
|
if c, err := r.Cookie("lang"); err == nil {
|
||||||
|
if supportedLangs[c.Value] {
|
||||||
|
return c.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, part := range strings.Split(r.Header.Get("Accept-Language"), ",") {
|
||||||
|
tag := strings.TrimSpace(strings.SplitN(part, ";", 2)[0])
|
||||||
|
code := strings.ToLower(strings.SplitN(tag, "-", 2)[0])
|
||||||
|
if supportedLangs[code] {
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultLang
|
||||||
|
}
|
||||||
|
|
||||||
|
// setLangCookie writes the lang preference cookie.
|
||||||
|
func setLangCookie(w http.ResponseWriter, lang string, secure bool) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "lang",
|
||||||
|
Value: lang,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: false, // JS may read it for future enhancement
|
||||||
|
Secure: secure,
|
||||||
|
MaxAge: 365 * 24 * 3600,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
1016
apps/finance/services/api/main/locales/en.toml
Normal file
1016
apps/finance/services/api/main/locales/en.toml
Normal file
File diff suppressed because it is too large
Load Diff
1016
apps/finance/services/api/main/locales/pt.toml
Normal file
1016
apps/finance/services/api/main/locales/pt.toml
Normal file
File diff suppressed because it is too large
Load Diff
@ -121,6 +121,7 @@ type RecurringExpense struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DashboardData struct {
|
type DashboardData struct {
|
||||||
|
T *Translator
|
||||||
UserID string
|
UserID string
|
||||||
Email string
|
Email string
|
||||||
Title string
|
Title string
|
||||||
@ -173,6 +174,7 @@ type BalancePoint struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ReportData struct {
|
type ReportData struct {
|
||||||
|
T *Translator
|
||||||
UserID string
|
UserID string
|
||||||
Email string
|
Email string
|
||||||
Title string
|
Title string
|
||||||
@ -188,6 +190,7 @@ type MonthlyCategorySummary struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ProjectionData struct {
|
type ProjectionData struct {
|
||||||
|
T *Translator
|
||||||
UserID string
|
UserID string
|
||||||
Email string
|
Email string
|
||||||
Title string
|
Title string
|
||||||
@ -198,6 +201,7 @@ type ProjectionData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PortfolioData struct {
|
type PortfolioData struct {
|
||||||
|
T *Translator
|
||||||
UserID string
|
UserID string
|
||||||
Email string
|
Email string
|
||||||
Title string
|
Title string
|
||||||
@ -213,6 +217,7 @@ type PortfolioData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SharingData struct {
|
type SharingData struct {
|
||||||
|
T *Translator
|
||||||
UserID string
|
UserID string
|
||||||
Email string
|
Email string
|
||||||
Title string
|
Title string
|
||||||
@ -244,6 +249,7 @@ type CapitalGainEntry struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TaxData struct {
|
type TaxData struct {
|
||||||
|
T *Translator
|
||||||
UserID string
|
UserID string
|
||||||
Email string
|
Email string
|
||||||
Title string
|
Title string
|
||||||
@ -275,6 +281,7 @@ type Household struct {
|
|||||||
|
|
||||||
// PeopleData combines Sharing and Household into a single page.
|
// PeopleData combines Sharing and Household into a single page.
|
||||||
type PeopleData struct {
|
type PeopleData struct {
|
||||||
|
T *Translator
|
||||||
UserID string
|
UserID string
|
||||||
Email string
|
Email string
|
||||||
Title string
|
Title string
|
||||||
@ -302,6 +309,7 @@ type PeopleData struct {
|
|||||||
|
|
||||||
// SettingsData combines Accounts and Categories into a single page.
|
// SettingsData combines Accounts and Categories into a single page.
|
||||||
type SettingsData struct {
|
type SettingsData struct {
|
||||||
|
T *Translator
|
||||||
UserID string
|
UserID string
|
||||||
Email string
|
Email string
|
||||||
Title string
|
Title string
|
||||||
@ -312,6 +320,7 @@ type SettingsData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type HouseholdData struct {
|
type HouseholdData struct {
|
||||||
|
T *Translator
|
||||||
UserID string
|
UserID string
|
||||||
Email string
|
Email string
|
||||||
Title string
|
Title string
|
||||||
@ -348,6 +357,7 @@ type ImportSchedule struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AutoImportData struct {
|
type AutoImportData struct {
|
||||||
|
T *Translator
|
||||||
UserID string
|
UserID string
|
||||||
Email string
|
Email string
|
||||||
Title string
|
Title string
|
||||||
@ -377,6 +387,7 @@ type SimulatorGoal struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SimulatorData struct {
|
type SimulatorData struct {
|
||||||
|
T *Translator
|
||||||
UserID string
|
UserID string
|
||||||
Email string
|
Email string
|
||||||
Title string
|
Title string
|
||||||
@ -409,6 +420,7 @@ type NetWorthPoint struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type NetWorthData struct {
|
type NetWorthData struct {
|
||||||
|
T *Translator
|
||||||
UserID string
|
UserID string
|
||||||
Email string
|
Email string
|
||||||
Title string
|
Title string
|
||||||
@ -463,6 +475,7 @@ type GoalPlan struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GoalsData struct {
|
type GoalsData struct {
|
||||||
|
T *Translator
|
||||||
UserID string
|
UserID string
|
||||||
Email string
|
Email string
|
||||||
Title string
|
Title string
|
||||||
|
|||||||
@ -80,6 +80,7 @@ type PropertyView struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PropertyData struct {
|
type PropertyData struct {
|
||||||
|
T *Translator
|
||||||
UserID string
|
UserID string
|
||||||
Email string
|
Email string
|
||||||
Title string
|
Title string
|
||||||
|
|||||||
@ -1,24 +1,24 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
{{$d := .}}
|
{{$d := .}}
|
||||||
<h1 style="margin-bottom:24px;">Accounts</h1>
|
<h1 style="margin-bottom:24px;">{{$d.T.Get "accounts.title"}}</h1>
|
||||||
|
|
||||||
<div class="card animate-on-scroll">
|
<div class="card animate-on-scroll">
|
||||||
<h2 style="margin-bottom:16px;">Add Account</h2>
|
<h2 style="margin-bottom:16px;">{{$d.T.Get "accounts.add_title"}}</h2>
|
||||||
<form method="POST" class="flex flex-wrap" style="gap:10px; align-items:flex-end;">
|
<form method="POST" class="flex flex-wrap" style="gap:10px; align-items:flex-end;">
|
||||||
<div class="form-group" style="margin-bottom:0; flex:1; min-width:180px;">
|
<div class="form-group" style="margin-bottom:0; flex:1; min-width:180px;">
|
||||||
<label>Account Name</label>
|
<label>{{$d.T.Get "accounts.label_name"}}</label>
|
||||||
<input type="text" name="name" placeholder="e.g. CGD Checking" required>
|
<input type="text" name="name" placeholder="{{$d.T.Get "accounts.placeholder_name"}}" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="margin-bottom:0; min-width:160px;">
|
<div class="form-group" style="margin-bottom:0; min-width:160px;">
|
||||||
<label>Type</label>
|
<label>{{$d.T.Get "accounts.label_type"}}</label>
|
||||||
<select name="type">
|
<select name="type">
|
||||||
<option value="checking">Checking</option>
|
<option value="checking">{{$d.T.Get "accounts.type_checking"}}</option>
|
||||||
<option value="savings">Savings</option>
|
<option value="savings">{{$d.T.Get "accounts.type_savings"}}</option>
|
||||||
<option value="credit">Credit Card</option>
|
<option value="credit">{{$d.T.Get "accounts.type_credit"}}</option>
|
||||||
<option value="securities">Securities</option>
|
<option value="securities">{{$d.T.Get "accounts.type_securities"}}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary">Add</button>
|
<button type="submit" class="btn btn-primary">{{$d.T.Get "accounts.btn_add"}}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -27,8 +27,8 @@
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>{{$d.T.Get "accounts.col_name"}}</th>
|
||||||
<th>Type</th>
|
<th>{{$d.T.Get "accounts.col_type"}}</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -46,12 +46,12 @@
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<button class="btn btn-danger btn-sm" onclick="delAccount('{{.ID}}')">Delete</button>
|
<button class="btn btn-danger btn-sm" onclick="delAccount('{{.ID}}')">{{$d.T.Get "accounts.btn_delete"}}</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="3" class="text-center text-muted" style="padding:36px;">No accounts yet.</td>
|
<td colspan="3" class="text-center text-muted" style="padding:36px;">{{$d.T.Get "accounts.empty_msg"}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -60,8 +60,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
const CONFIRM_DELETE = {{$d.T.Get "accounts.confirm_delete" | printf "%q"}};
|
||||||
function delAccount(id) {
|
function delAccount(id) {
|
||||||
if (!confirm('Delete this account?')) return;
|
if (!confirm(CONFIRM_DELETE)) return;
|
||||||
fetch('/accounts/' + id, {method: 'DELETE'}).then(r => {
|
fetch('/accounts/' + id, {method: 'DELETE'}).then(r => {
|
||||||
if (r.ok) document.getElementById('acct-row-' + id).remove();
|
if (r.ok) document.getElementById('acct-row-' + id).remove();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -18,29 +18,29 @@ code { font-family:monospace; font-size:0.82rem; background:var(--bg); border:1p
|
|||||||
|
|
||||||
<div style="display:flex; align-items:center; gap:14px; margin-bottom:28px;">
|
<div style="display:flex; align-items:center; gap:14px; margin-bottom:28px;">
|
||||||
<div>
|
<div>
|
||||||
<h1 style="margin:0 0 4px;">Import Guide</h1>
|
<h1 style="margin:0 0 4px;">{{$d.T.Get "auto_import.title"}}</h1>
|
||||||
<p style="margin:0; font-size:0.85rem; color:var(--muted);">How to get your bank transactions into the app</p>
|
<p style="margin:0; font-size:0.85rem; color:var(--muted);">{{$d.T.Get "auto_import.subtitle"}}</p>
|
||||||
</div>
|
</div>
|
||||||
<a href="/import" class="btn btn-primary" style="margin-left:auto;">Upload CSV →</a>
|
<a href="/import" class="btn btn-primary" style="margin-left:auto;">{{$d.T.Get "auto_import.btn_upload"}}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="guide-step">
|
<div class="guide-step">
|
||||||
<div class="step-num">1</div>
|
<div class="step-num">1</div>
|
||||||
<div class="step-body">
|
<div class="step-body">
|
||||||
<h3>Export a CSV from your bank</h3>
|
<h3>{{$d.T.Get "auto_import.steps.step1_title"}}</h3>
|
||||||
<p>Log into your bank's online portal and download a transaction extract for the period you want. Most Portuguese banks offer this under <em>Movimentos</em> or <em>Extratos</em>.</p>
|
<p>{{$d.T.Get "auto_import.steps.step1_body"}}</p>
|
||||||
<div class="format-grid" style="margin-top:14px;">
|
<div class="format-grid" style="margin-top:14px;">
|
||||||
<div class="format-card">
|
<div class="format-card">
|
||||||
<div class="name">CGD (Caixa Geral de Depósitos)</div>
|
<div class="name">{{$d.T.Get "auto_import.steps.step1_cgd_name"}}</div>
|
||||||
<div class="desc">Netbanco → Consultas → Movimentos de Conta → Exportar. Choose <strong>CSV</strong>.</div>
|
<div class="desc">{{$d.T.Get "auto_import.steps.step1_cgd_desc"}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="format-card">
|
<div class="format-card">
|
||||||
<div class="name">Trade Republic</div>
|
<div class="name">{{$d.T.Get "auto_import.steps.step1_tr_name"}}</div>
|
||||||
<div class="desc">App → Profile → Documents → Activity. Export as CSV.</div>
|
<div class="desc">{{$d.T.Get "auto_import.steps.step1_tr_desc"}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="format-card">
|
<div class="format-card">
|
||||||
<div class="name">Generic / Other banks</div>
|
<div class="name">{{$d.T.Get "auto_import.steps.step1_generic_name"}}</div>
|
||||||
<div class="desc">Any CSV with columns: <code>Date</code>, <code>Description</code>, <code>Amount</code>. The importer auto-detects column order.</div>
|
<div class="desc">{{$d.T.Get "auto_import.steps.step1_generic_desc"}}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -49,27 +49,27 @@ code { font-family:monospace; font-size:0.82rem; background:var(--bg); border:1p
|
|||||||
<div class="guide-step">
|
<div class="guide-step">
|
||||||
<div class="step-num">2</div>
|
<div class="step-num">2</div>
|
||||||
<div class="step-body">
|
<div class="step-body">
|
||||||
<h3>Upload and preview</h3>
|
<h3>{{$d.T.Get "auto_import.steps.step2_title"}}</h3>
|
||||||
<p>Go to <a href="/import" style="color:var(--accent);">Import</a>, pick the account and format, then select your file. You'll see a preview of every row with auto-suggested categories. Rows already in the database are shown greyed out and will be skipped automatically — safe to re-upload the same file.</p>
|
<p>{{$d.T.Get "auto_import.steps.step2_body"}}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="guide-step">
|
<div class="guide-step">
|
||||||
<div class="step-num">3</div>
|
<div class="step-num">3</div>
|
||||||
<div class="step-body">
|
<div class="step-body">
|
||||||
<h3>Review categories and confirm</h3>
|
<h3>{{$d.T.Get "auto_import.steps.step3_title"}}</h3>
|
||||||
<p>Adjust any auto-categorised rows using the dropdown selectors, then click <strong>Confirm Import</strong>. Only new transactions are saved.</p>
|
<p>{{$d.T.Get "auto_import.steps.step3_body"}}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tip">
|
<div class="tip">
|
||||||
<strong>Tip — avoid duplicates:</strong> the importer fingerprints each row by date, description, amount, and account. Re-uploading a file that overlaps with a previous import is safe; duplicates are detected and skipped at both preview and confirm time.
|
<strong>{{$d.T.Get "auto_import.tip.title"}}</strong> {{$d.T.Get "auto_import.tip.body"}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-top:28px; background:var(--card); border:1px solid var(--border); border-radius:12px; padding:20px 24px;">
|
<div style="margin-top:28px; background:var(--card); border:1px solid var(--border); border-radius:12px; padding:20px 24px;">
|
||||||
<h3 style="margin:0 0 8px; font-size:0.95rem;">Automate with a script</h3>
|
<h3 style="margin:0 0 8px; font-size:0.95rem;">{{$d.T.Get "auto_import.api.title"}}</h3>
|
||||||
<p style="font-size:0.85rem; color:var(--muted); margin:0 0 14px;">
|
<p style="font-size:0.85rem; color:var(--muted); margin:0 0 14px;">
|
||||||
If you export your CSV on a schedule (e.g. via a cron job or n8n), you can push it directly to the import endpoint without going through the UI:
|
{{$d.T.Get "auto_import.api.body"}}
|
||||||
</p>
|
</p>
|
||||||
<pre style="background:var(--bg); border:1px solid var(--border); border-radius:8px; padding:14px 16px; font-size:0.8rem; overflow-x:auto; margin:0;">curl -X POST https://<your-host>/import/preview \
|
<pre style="background:var(--bg); border:1px solid var(--border); border-radius:8px; padding:14px 16px; font-size:0.8rem; overflow-x:auto; margin:0;">curl -X POST https://<your-host>/import/preview \
|
||||||
-H "X-Auth-User-Id: <user-id>" \
|
-H "X-Auth-User-Id: <user-id>" \
|
||||||
@ -77,6 +77,6 @@ code { font-family:monospace; font-size:0.82rem; background:var(--bg); border:1p
|
|||||||
-F "account_id=<account-id>" \
|
-F "account_id=<account-id>" \
|
||||||
-F "format=cgd" \
|
-F "format=cgd" \
|
||||||
-F "file=@movements.csv"</pre>
|
-F "file=@movements.csv"</pre>
|
||||||
<p style="font-size:0.8rem; color:var(--muted); margin:12px 0 0;">The preview endpoint returns an HTML page; for headless import pipe the confirmed rows to <code>POST /import/confirm</code> with the same <code>account_id</code>, <code>format</code>, <code>raw_data</code>, and <code>categories[]</code> fields.</p>
|
<p style="font-size:0.8rem; color:var(--muted); margin:12px 0 0;">{{$d.T.Get "auto_import.api.footer"}}</p>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@ -572,76 +572,82 @@
|
|||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<a href="/dashboard" class="nav-brand">
|
<a href="/dashboard" class="nav-brand">
|
||||||
<div class="nav-brand-icon">₣</div>
|
<div class="nav-brand-icon">₣</div>
|
||||||
Personal
|
{{.T.Get "nav.brand"}}
|
||||||
</a>
|
</a>
|
||||||
<a href="/dashboard" class="{{if eq .Route "dashboard"}}active{{end}}">Dashboard</a>
|
<a href="/dashboard" class="{{if eq .Route "dashboard"}}active{{end}}">{{.T.Get "nav.dashboard"}}</a>
|
||||||
<a href="/transactions" class="{{if eq .Route "transactions"}}active{{end}}">Transactions</a>
|
<a href="/transactions" class="{{if eq .Route "transactions"}}active{{end}}">{{.T.Get "nav.transactions"}}</a>
|
||||||
<a href="/portfolio" class="{{if eq .Route "portfolio"}}active{{end}}">Portfolio</a>
|
<a href="/portfolio" class="{{if eq .Route "portfolio"}}active{{end}}">{{.T.Get "nav.portfolio"}}</a>
|
||||||
<a href="/goals" class="{{if eq .Route "goals"}}active{{end}}">Goals</a>
|
<a href="/goals" class="{{if eq .Route "goals"}}active{{end}}">{{.T.Get "nav.goals"}}</a>
|
||||||
<a href="/property" class="{{if eq .Route "property"}}active{{end}}">Property</a>
|
<a href="/property" class="{{if eq .Route "property"}}active{{end}}">{{.T.Get "nav.property"}}</a>
|
||||||
|
|
||||||
|
|
||||||
{{$analysisActive := or (eq .Route "reports") (eq .Route "projections") (eq .Route "networth") (eq .Route "simulator") (eq .Route "tax")}}
|
{{$analysisActive := or (eq .Route "reports") (eq .Route "projections") (eq .Route "networth") (eq .Route "simulator") (eq .Route "tax")}}
|
||||||
<div class="nav-group">
|
<div class="nav-group">
|
||||||
<button class="nav-group-btn {{if $analysisActive}}active{{end}}">
|
<button class="nav-group-btn {{if $analysisActive}}active{{end}}">
|
||||||
Analysis <svg viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 1l4 4 4-4"/></svg>
|
{{.T.Get "nav.analysis"}} <svg viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 1l4 4 4-4"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="nav-dropdown">
|
<div class="nav-dropdown">
|
||||||
<a href="/reports" class="{{if eq .Route "reports"}}active{{end}}">Reports</a>
|
<a href="/reports" class="{{if eq .Route "reports"}}active{{end}}">{{.T.Get "nav.analysis.reports"}}</a>
|
||||||
<a href="/projections" class="{{if eq .Route "projections"}}active{{end}}">Projections</a>
|
<a href="/projections" class="{{if eq .Route "projections"}}active{{end}}">{{.T.Get "nav.analysis.projections"}}</a>
|
||||||
<a href="/tax" class="{{if eq .Route "tax"}}active{{end}}">Tax</a>
|
<a href="/tax" class="{{if eq .Route "tax"}}active{{end}}">{{.T.Get "nav.analysis.tax"}}</a>
|
||||||
<hr>
|
<hr>
|
||||||
<a href="/networth" class="{{if eq .Route "networth"}}active{{end}}">Net Worth</a>
|
<a href="/networth" class="{{if eq .Route "networth"}}active{{end}}">{{.T.Get "nav.analysis.networth"}}</a>
|
||||||
<a href="/simulator" class="{{if eq .Route "simulator"}}active{{end}}">What If</a>
|
<a href="/simulator" class="{{if eq .Route "simulator"}}active{{end}}">{{.T.Get "nav.analysis.simulator"}}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a href="/people" class="{{if eq .Route "people"}}active{{end}}">People</a>
|
<a href="/people" class="{{if eq .Route "people"}}active{{end}}">{{.T.Get "nav.people"}}</a>
|
||||||
|
|
||||||
{{$settingsActive := or (eq .Route "settings") (eq .Route "auto-import")}}
|
{{$settingsActive := or (eq .Route "settings") (eq .Route "auto-import")}}
|
||||||
<div class="nav-group">
|
<div class="nav-group">
|
||||||
<button class="nav-group-btn {{if $settingsActive}}active{{end}}">
|
<button class="nav-group-btn {{if $settingsActive}}active{{end}}">
|
||||||
Settings <svg viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 1l4 4 4-4"/></svg>
|
{{.T.Get "nav.settings"}} <svg viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 1l4 4 4-4"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="nav-dropdown">
|
<div class="nav-dropdown">
|
||||||
<a href="/settings?tab=accounts" class="{{if eq .Route "settings"}}active{{end}}">Accounts & Categories</a>
|
<a href="/settings?tab=accounts" class="{{if eq .Route "settings"}}active{{end}}">{{.T.Get "nav.settings.accounts_categories"}}</a>
|
||||||
<a href="/import" class="{{if eq .Route "import"}}active{{end}}">Import CSV</a>
|
<a href="/import" class="{{if eq .Route "import"}}active{{end}}">{{.T.Get "nav.settings.import_csv"}}</a>
|
||||||
<a href="/auto-import" class="{{if eq .Route "auto-import"}}active{{end}}">Import Guide</a>
|
<a href="/auto-import" class="{{if eq .Route "auto-import"}}active{{end}}">{{.T.Get "nav.settings.import_guide"}}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-spacer"></div>
|
<div class="nav-spacer"></div>
|
||||||
<a href="/orgs" style="font-size:12px; color:var(--text3); padding:5px 9px; border:1px solid var(--border); border-radius:var(--radius-sm); text-decoration:none; transition:all 0.18s;" onmouseover="this.style.color='var(--text2)';this.style.borderColor='var(--border2)'" onmouseout="this.style.color='var(--text3)';this.style.borderColor='var(--border)'">🏢 Business</a>
|
<a href="/orgs" style="font-size:12px; color:var(--text3); padding:5px 9px; border:1px solid var(--border); border-radius:var(--radius-sm); text-decoration:none; transition:all 0.18s;" onmouseover="this.style.color='var(--text2)';this.style.borderColor='var(--border2)'" onmouseout="this.style.color='var(--text3)';this.style.borderColor='var(--border)'">{{.T.Get "nav.business"}}</a>
|
||||||
<span class="nav-email">{{.Email}}</span>
|
<span class="nav-email">{{.Email}}</span>
|
||||||
<button class="theme-btn" id="theme-toggle" title="Toggle dark/light mode">🌙</button>
|
<form method="POST" action="/lang" style="display:inline;">
|
||||||
<button class="nav-hamburger" id="nav-hamburger" aria-label="Menu">
|
<select name="lang" onchange="this.form.submit()" style="font-size:12px; background:var(--bg2); color:var(--text2); border:1px solid var(--border2); border-radius:var(--radius-sm); padding:4px 6px; cursor:pointer;">
|
||||||
|
<option value="en" {{if eq .Lang "en"}}selected{{end}}>EN</option>
|
||||||
|
<option value="pt" {{if eq .Lang "pt"}}selected{{end}}>PT</option>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
<button class="theme-btn" id="theme-toggle" title="{{.T.Get "nav.theme.toggle_title"}}">🌙</button>
|
||||||
|
<button class="nav-hamburger" id="nav-hamburger" aria-label="{{.T.Get "nav.theme.menu_aria"}}">
|
||||||
<span></span><span></span><span></span>
|
<span></span><span></span><span></span>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Mobile drawer -->
|
<!-- Mobile drawer -->
|
||||||
<div class="nav-drawer" id="nav-drawer">
|
<div class="nav-drawer" id="nav-drawer">
|
||||||
<a href="/dashboard" class="{{if eq .Route "dashboard"}}active{{end}}">Dashboard</a>
|
<a href="/dashboard" class="{{if eq .Route "dashboard"}}active{{end}}">{{.T.Get "nav.dashboard"}}</a>
|
||||||
<a href="/transactions" class="{{if eq .Route "transactions"}}active{{end}}">Transactions</a>
|
<a href="/transactions" class="{{if eq .Route "transactions"}}active{{end}}">{{.T.Get "nav.transactions"}}</a>
|
||||||
<a href="/portfolio" class="{{if eq .Route "portfolio"}}active{{end}}">Portfolio</a>
|
<a href="/portfolio" class="{{if eq .Route "portfolio"}}active{{end}}">{{.T.Get "nav.portfolio"}}</a>
|
||||||
<a href="/goals" class="{{if eq .Route "goals"}}active{{end}}">Goals</a>
|
<a href="/goals" class="{{if eq .Route "goals"}}active{{end}}">{{.T.Get "nav.goals"}}</a>
|
||||||
<a href="/property" class="{{if eq .Route "property"}}active{{end}}">Property</a>
|
<a href="/property" class="{{if eq .Route "property"}}active{{end}}">{{.T.Get "nav.property"}}</a>
|
||||||
|
|
||||||
<a href="/people" class="{{if eq .Route "people"}}active{{end}}">People</a>
|
<a href="/people" class="{{if eq .Route "people"}}active{{end}}">{{.T.Get "nav.people"}}</a>
|
||||||
<hr>
|
<hr>
|
||||||
<span class="nav-drawer-section-label">Analysis</span>
|
<span class="nav-drawer-section-label">{{.T.Get "nav.drawer.analysis_label"}}</span>
|
||||||
<a href="/reports" class="{{if eq .Route "reports"}}active{{end}}">Reports</a>
|
<a href="/reports" class="{{if eq .Route "reports"}}active{{end}}">{{.T.Get "nav.analysis.reports"}}</a>
|
||||||
<a href="/projections" class="{{if eq .Route "projections"}}active{{end}}">Projections</a>
|
<a href="/projections" class="{{if eq .Route "projections"}}active{{end}}">{{.T.Get "nav.analysis.projections"}}</a>
|
||||||
<a href="/tax" class="{{if eq .Route "tax"}}active{{end}}">Tax</a>
|
<a href="/tax" class="{{if eq .Route "tax"}}active{{end}}">{{.T.Get "nav.analysis.tax"}}</a>
|
||||||
<a href="/networth" class="{{if eq .Route "networth"}}active{{end}}">Net Worth</a>
|
<a href="/networth" class="{{if eq .Route "networth"}}active{{end}}">{{.T.Get "nav.analysis.networth"}}</a>
|
||||||
<a href="/simulator" class="{{if eq .Route "simulator"}}active{{end}}">What If</a>
|
<a href="/simulator" class="{{if eq .Route "simulator"}}active{{end}}">{{.T.Get "nav.analysis.simulator"}}</a>
|
||||||
<hr>
|
<hr>
|
||||||
<span class="nav-drawer-section-label">Settings</span>
|
<span class="nav-drawer-section-label">{{.T.Get "nav.drawer.settings_label"}}</span>
|
||||||
<a href="/settings?tab=accounts" class="{{if eq .Route "settings"}}active{{end}}">Accounts & Categories</a>
|
<a href="/settings?tab=accounts" class="{{if eq .Route "settings"}}active{{end}}">{{.T.Get "nav.settings.accounts_categories"}}</a>
|
||||||
<a href="/import" class="{{if eq .Route "import"}}active{{end}}">Import CSV</a>
|
<a href="/import" class="{{if eq .Route "import"}}active{{end}}">{{.T.Get "nav.settings.import_csv"}}</a>
|
||||||
<a href="/auto-import" class="{{if eq .Route "auto-import"}}active{{end}}">Import Guide</a>
|
<a href="/auto-import" class="{{if eq .Route "auto-import"}}active{{end}}">{{.T.Get "nav.settings.import_guide"}}</a>
|
||||||
<hr>
|
<hr>
|
||||||
<a href="/orgs">🏢 Business</a>
|
<a href="/orgs">{{.T.Get "nav.business"}}</a>
|
||||||
<a href="/">← Hub</a>
|
<a href="/">{{.T.Get "nav.hub_back"}}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|||||||
@ -1,19 +1,19 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
{{$d := .}}
|
{{$d := .}}
|
||||||
<h1 style="margin-bottom:24px;">Categories</h1>
|
<h1 style="margin-bottom:24px;">{{$d.T.Get "categories.title"}}</h1>
|
||||||
|
|
||||||
<div class="card animate-on-scroll">
|
<div class="card animate-on-scroll">
|
||||||
<h2 style="margin-bottom:16px;">Add Category</h2>
|
<h2 style="margin-bottom:16px;">{{$d.T.Get "categories.add_title"}}</h2>
|
||||||
<form method="POST" class="flex flex-wrap" style="gap:10px; align-items:flex-end;">
|
<form method="POST" class="flex flex-wrap" style="gap:10px; align-items:flex-end;">
|
||||||
<div class="form-group" style="margin-bottom:0; flex:1; min-width:160px;">
|
<div class="form-group" style="margin-bottom:0; flex:1; min-width:160px;">
|
||||||
<label>Name</label>
|
<label>{{$d.T.Get "categories.label_name"}}</label>
|
||||||
<input type="text" name="name" placeholder="e.g. Dining" required>
|
<input type="text" name="name" placeholder="{{$d.T.Get "categories.placeholder_name"}}" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="margin-bottom:0; width:90px;">
|
<div class="form-group" style="margin-bottom:0; width:90px;">
|
||||||
<label>Color</label>
|
<label>{{$d.T.Get "categories.label_color"}}</label>
|
||||||
<input type="color" name="color" value="#7986CB">
|
<input type="color" name="color" value="#7986CB">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary">Add</button>
|
<button type="submit" class="btn btn-primary">{{$d.T.Get "categories.btn_add"}}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -23,8 +23,8 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width:44px;"></th>
|
<th style="width:44px;"></th>
|
||||||
<th>Name</th>
|
<th>{{$d.T.Get "categories.col_name"}}</th>
|
||||||
<th>Monthly Budget</th>
|
<th>{{$d.T.Get "categories.col_budget"}}</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -38,27 +38,27 @@
|
|||||||
<td style="font-weight:500;">{{.Name}}</td>
|
<td style="font-weight:500;">{{.Name}}</td>
|
||||||
<td>
|
<td>
|
||||||
<span id="budget-display-{{.ID}}" style="font-size:13.5px; color:var(--text2);">
|
<span id="budget-display-{{.ID}}" style="font-size:13.5px; color:var(--text2);">
|
||||||
{{if gt .BudgetCents 0}}€{{cents .BudgetCents}}{{else}}<span class="text-muted">No budget</span>{{end}}
|
{{if gt .BudgetCents 0}}€{{cents .BudgetCents}}{{else}}<span class="text-muted">{{$d.T.Get "categories.no_budget"}}</span>{{end}}
|
||||||
</span>
|
</span>
|
||||||
<span id="budget-edit-{{.ID}}" style="display:none; align-items:center; gap:6px;">
|
<span id="budget-edit-{{.ID}}" style="display:none; align-items:center; gap:6px;">
|
||||||
<input type="number" id="budget-input-{{.ID}}" placeholder="0.00" step="0.01" min="0"
|
<input type="number" id="budget-input-{{.ID}}" placeholder="0.00" step="0.01" min="0"
|
||||||
value="{{if gt .BudgetCents 0}}{{div .BudgetCents 100}}{{end}}"
|
value="{{if gt .BudgetCents 0}}{{div .BudgetCents 100}}{{end}}"
|
||||||
style="width:110px; padding:5px 9px; border:1px solid var(--border2);
|
style="width:110px; padding:5px 9px; border:1px solid var(--border2);
|
||||||
border-radius:6px; font-size:13px; background:var(--bg2); color:var(--text);">
|
border-radius:6px; font-size:13px; background:var(--bg2); color:var(--text);">
|
||||||
<button class="btn btn-primary btn-sm" onclick="saveBudget('{{.ID}}')">Save</button>
|
<button class="btn btn-primary btn-sm" onclick="saveBudget('{{.ID}}')">{{$d.T.Get "categories.btn_save_budget"}}</button>
|
||||||
<button class="btn btn-outline btn-sm" onclick="cancelBudget('{{.ID}}')">Cancel</button>
|
<button class="btn btn-outline btn-sm" onclick="cancelBudget('{{.ID}}')">{{$d.T.Get "categories.btn_cancel_budget"}}</button>
|
||||||
</span>
|
</span>
|
||||||
<button class="btn btn-outline btn-sm" id="budget-btn-{{.ID}}"
|
<button class="btn btn-outline btn-sm" id="budget-btn-{{.ID}}"
|
||||||
onclick="editBudget('{{.ID}}')" style="margin-left:8px;">Edit</button>
|
onclick="editBudget('{{.ID}}')" style="margin-left:8px;">{{$d.T.Get "categories.btn_edit_budget"}}</button>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn-danger btn-sm" onclick="delCat('{{.ID}}')">Delete</button>
|
<button class="btn btn-danger btn-sm" onclick="delCat('{{.ID}}')">{{$d.T.Get "categories.btn_delete"}}</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="4" class="text-center text-muted" style="padding:36px;">
|
<td colspan="4" class="text-center text-muted" style="padding:36px;">
|
||||||
No categories yet. Add one above.
|
{{$d.T.Get "categories.empty_msg"}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
@ -68,6 +68,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
const NO_BUDGET_LABEL = {{$d.T.Get "categories.no_budget" | printf "%q"}};
|
||||||
|
const CONFIRM_DELETE = {{$d.T.Get "categories.confirm_delete" | printf "%q"}};
|
||||||
|
|
||||||
function editBudget(id) {
|
function editBudget(id) {
|
||||||
document.getElementById('budget-display-' + id).style.display = 'none';
|
document.getElementById('budget-display-' + id).style.display = 'none';
|
||||||
document.getElementById('budget-btn-' + id).style.display = 'none';
|
document.getElementById('budget-btn-' + id).style.display = 'none';
|
||||||
@ -93,12 +96,12 @@ function saveBudget(id) {
|
|||||||
const d = document.getElementById('budget-display-' + id);
|
const d = document.getElementById('budget-display-' + id);
|
||||||
d.innerHTML = cents > 0
|
d.innerHTML = cents > 0
|
||||||
? '€' + (cents / 100).toLocaleString('pt-PT', {minimumFractionDigits:2})
|
? '€' + (cents / 100).toLocaleString('pt-PT', {minimumFractionDigits:2})
|
||||||
: '<span class="text-muted">No budget</span>';
|
: '<span class="text-muted">' + NO_BUDGET_LABEL + '</span>';
|
||||||
cancelBudget(id);
|
cancelBudget(id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function delCat(id) {
|
function delCat(id) {
|
||||||
if (!confirm('Delete this category? Transactions keep their label.')) return;
|
if (!confirm(CONFIRM_DELETE)) return;
|
||||||
fetch('/categories/' + id, {method: 'DELETE'}).then(r => {
|
fetch('/categories/' + id, {method: 'DELETE'}).then(r => {
|
||||||
if (r.ok) document.getElementById('cat-row-' + id).remove();
|
if (r.ok) document.getElementById('cat-row-' + id).remove();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
{{$d := .}}
|
{{$d := .}}
|
||||||
|
|
||||||
<div style="display:flex; align-items:baseline; justify-content:space-between; margin-bottom:24px; flex-wrap:wrap; gap:8px;">
|
<div style="display:flex; align-items:baseline; justify-content:space-between; margin-bottom:24px; flex-wrap:wrap; gap:8px;">
|
||||||
<h1>Dashboard</h1>
|
<h1>{{$d.T.Get "dashboard.title"}}</h1>
|
||||||
<span class="text-muted">{{if $d.Email}}{{$d.Email}}{{end}}</span>
|
<span class="text-muted">{{if $d.Email}}{{$d.Email}}{{end}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -23,9 +23,9 @@
|
|||||||
<!-- HERO: available to spend -->
|
<!-- HERO: available to spend -->
|
||||||
<div class="card animate-on-scroll" style="margin-bottom:16px; padding:24px 28px;">
|
<div class="card animate-on-scroll" style="margin-bottom:16px; padding:24px 28px;">
|
||||||
<div style="font-size:12px; color:var(--text2); text-transform:uppercase; letter-spacing:.5px; margin-bottom:10px; display:flex; align-items:center; gap:8px;">
|
<div style="font-size:12px; color:var(--text2); text-transform:uppercase; letter-spacing:.5px; margin-bottom:10px; display:flex; align-items:center; gap:8px;">
|
||||||
Available to spend this month
|
{{$d.T.Get "dashboard.available_to_spend"}}
|
||||||
<span style="font-size:11px; background:var(--bg3); color:var(--text3); padding:2px 8px; border-radius:99px; font-weight:400; text-transform:none; letter-spacing:0;">
|
<span style="font-size:11px; background:var(--bg3); color:var(--text3); padding:2px 8px; border-radius:99px; font-weight:400; text-transform:none; letter-spacing:0;">
|
||||||
income − fixed costs − spent so far
|
{{$d.T.Get "dashboard.available_formula"}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -34,7 +34,7 @@
|
|||||||
style="font-size:42px; font-weight:500; letter-spacing:-1.5px; line-height:1;"
|
style="font-size:42px; font-weight:500; letter-spacing:-1.5px; line-height:1;"
|
||||||
data-target="{{$d.AvailableToSpend}}" data-prefix="€">€0.00</div>
|
data-target="{{$d.AvailableToSpend}}" data-prefix="€">€0.00</div>
|
||||||
<div style="font-size:13px; color:var(--text2);">
|
<div style="font-size:13px; color:var(--text2);">
|
||||||
of <span style="color:var(--text); font-weight:500;" class="animate-counter" data-target="{{$d.DisposableIncome}}" data-prefix="€">€0.00</span> disposable
|
of <span style="color:var(--text); font-weight:500;" class="animate-counter" data-target="{{$d.DisposableIncome}}" data-prefix="€">€0.00</span> {{$d.T.Get "dashboard.disposable_label"}}
|
||||||
· <span style="color:var(--text2);">{{$d.MonthSpentPct}}% used</span>
|
· <span style="color:var(--text2);">{{$d.MonthSpentPct}}% used</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -54,30 +54,30 @@
|
|||||||
<div class="grid" style="margin-bottom:16px;">
|
<div class="grid" style="margin-bottom:16px;">
|
||||||
|
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Bank balance should be</h2>
|
<h2>{{$d.T.Get "dashboard.cards.bank_should_be"}}</h2>
|
||||||
<div class="value animate-counter" data-target="{{$d.BankShouldBe}}" data-prefix="€" style="color:var(--text);">€0.00</div>
|
<div class="value animate-counter" data-target="{{$d.BankShouldBe}}" data-prefix="€" style="color:var(--text);">€0.00</div>
|
||||||
<p style="font-size:12px; color:var(--text3); margin-top:6px;">upcoming fixed + safety buffer</p>
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "dashboard.cards.bank_should_be_sub"}}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Savings rate</h2>
|
<h2>{{$d.T.Get "dashboard.cards.savings_rate"}}</h2>
|
||||||
<div class="value {{if gt $d.SavingsRatePct 0}}positive{{else}}negative{{end}}">{{$d.SavingsRatePct}}%</div>
|
<div class="value {{if gt $d.SavingsRatePct 0}}positive{{else}}negative{{end}}">{{$d.SavingsRatePct}}%</div>
|
||||||
{{if $d.LastMonthSavingsRatePct}}
|
{{if $d.LastMonthSavingsRatePct}}
|
||||||
<p style="font-size:12px; margin-top:6px; {{if gt $d.SavingsRatePct $d.LastMonthSavingsRatePct}}color:var(--green){{else}}color:var(--red){{end}};">
|
<p style="font-size:12px; margin-top:6px; {{if gt $d.SavingsRatePct $d.LastMonthSavingsRatePct}}color:var(--green){{else}}color:var(--red){{end}};">
|
||||||
{{if gt $d.SavingsRatePct $d.LastMonthSavingsRatePct}}↑{{else}}↓{{end}} vs last month ({{$d.LastMonthSavingsRatePct}}%)
|
{{if gt $d.SavingsRatePct $d.LastMonthSavingsRatePct}}↑{{else}}↓{{end}} {{$d.T.Get "dashboard.alerts.vs_last_month_up"}} ({{$d.LastMonthSavingsRatePct}}%)
|
||||||
</p>
|
</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Net worth</h2>
|
<h2>{{$d.T.Get "dashboard.cards.net_worth"}}</h2>
|
||||||
<div class="value animate-counter {{if lt $d.NetWorthCents 0}}negative{{else}}positive{{end}}"
|
<div class="value animate-counter {{if lt $d.NetWorthCents 0}}negative{{else}}positive{{end}}"
|
||||||
data-target="{{$d.NetWorthCents}}" data-prefix="€">€0.00</div>
|
data-target="{{$d.NetWorthCents}}" data-prefix="€">€0.00</div>
|
||||||
<p style="font-size:12px; color:var(--text3); margin-top:6px;"><a href="/networth" style="color:var(--accent);">→ full breakdown</a></p>
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;"><a href="/networth" style="color:var(--accent);">{{$d.T.Get "dashboard.cards.net_worth_link"}}</a></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Portfolio today</h2>
|
<h2>{{$d.T.Get "dashboard.cards.portfolio_today"}}</h2>
|
||||||
{{if $d.PortfolioHoldings}}
|
{{if $d.PortfolioHoldings}}
|
||||||
<div class="value animate-counter" data-target="{{$d.PortfolioValueCents}}" data-prefix="€" style="color:var(--text);">€0.00</div>
|
<div class="value animate-counter" data-target="{{$d.PortfolioValueCents}}" data-prefix="€" style="color:var(--text);">€0.00</div>
|
||||||
{{if $d.PortfolioPricesAvailable}}
|
{{if $d.PortfolioPricesAvailable}}
|
||||||
@ -85,11 +85,11 @@
|
|||||||
{{if ge $d.PortfolioPCLCents 0}}+{{else}}−{{end}}€{{cents (centsAbs $d.PortfolioPCLCents)}} total P&L
|
{{if ge $d.PortfolioPCLCents 0}}+{{else}}−{{end}}€{{cents (centsAbs $d.PortfolioPCLCents)}} total P&L
|
||||||
</p>
|
</p>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p style="font-size:12px; color:var(--text3); margin-top:6px;">cost basis · prices unavailable</p>
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "dashboard.cards.portfolio_cost_basis"}}</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="value" style="color:var(--text3); font-size:18px;">No trades yet</div>
|
<div class="value" style="color:var(--text3); font-size:18px;">{{$d.T.Get "dashboard.cards.portfolio_no_trades"}}</div>
|
||||||
<p style="font-size:12px; color:var(--text3); margin-top:6px;"><a href="/import" style="color:var(--accent);">Import trades →</a></p>
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;"><a href="/import" style="color:var(--accent);">{{$d.T.Get "dashboard.cards.portfolio_import_link"}}</a></p>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -100,8 +100,8 @@
|
|||||||
|
|
||||||
<div class="card animate-on-scroll">
|
<div class="card animate-on-scroll">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
||||||
<h2>What should be in your bank</h2>
|
<h2>{{$d.T.Get "dashboard.bank_math.section_title"}}</h2>
|
||||||
<span style="font-size:11px; color:var(--text3);">right now</span>
|
<span style="font-size:11px; color:var(--text3);">{{$d.T.Get "dashboard.bank_math.section_subtitle"}}</span>
|
||||||
</div>
|
</div>
|
||||||
{{if $d.RecurringExpenses}}
|
{{if $d.RecurringExpenses}}
|
||||||
<div style="display:flex; flex-direction:column;">
|
<div style="display:flex; flex-direction:column;">
|
||||||
@ -117,27 +117,27 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
{{if $d.SafetyBufferCents}}
|
{{if $d.SafetyBufferCents}}
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:9px 0; border-bottom:1px solid var(--border);">
|
<div style="display:flex; justify-content:space-between; align-items:center; padding:9px 0; border-bottom:1px solid var(--border);">
|
||||||
<span style="font-size:13px; color:var(--text2);">Safety buffer (2 weeks)</span>
|
<span style="font-size:13px; color:var(--text2);">{{$d.T.Get "dashboard.bank_math.safety_buffer"}}</span>
|
||||||
<span style="font-size:13px; font-weight:500; color:var(--red);">− €{{cents $d.SafetyBufferCents}}</span>
|
<span style="font-size:13px; font-weight:500; color:var(--red);">− €{{cents $d.SafetyBufferCents}}</span>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:12px 0 0 0;">
|
<div style="display:flex; justify-content:space-between; align-items:center; padding:12px 0 0 0;">
|
||||||
<span style="font-size:13px; font-weight:500; color:var(--text);">Minimum recommended</span>
|
<span style="font-size:13px; font-weight:500; color:var(--text);">{{$d.T.Get "dashboard.bank_math.minimum_recommended"}}</span>
|
||||||
<span class="animate-counter positive" style="font-size:16px; font-weight:600;"
|
<span class="animate-counter positive" style="font-size:16px; font-weight:600;"
|
||||||
data-target="{{$d.BankShouldBe}}" data-prefix="€">€0.00</span>
|
data-target="{{$d.BankShouldBe}}" data-prefix="€">€0.00</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="empty-state" style="padding:24px;">
|
<div class="empty-state" style="padding:24px;">
|
||||||
<p>No recurring expenses detected yet.<br>Import a few months of transactions.</p>
|
<p>{{$d.T.Get "dashboard.bank_math.no_recurring_msg"}}<br>{{$d.T.Get "dashboard.bank_math.no_recurring_sub"}}</p>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card animate-on-scroll">
|
<div class="card animate-on-scroll">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
||||||
<h2>Stocks at a glance</h2>
|
<h2>{{$d.T.Get "dashboard.stocks.section_title"}}</h2>
|
||||||
<a href="/portfolio" style="font-size:12px; color:var(--text3);">→ portfolio</a>
|
<a href="/portfolio" style="font-size:12px; color:var(--text3);">{{$d.T.Get "dashboard.stocks.portfolio_link"}}</a>
|
||||||
</div>
|
</div>
|
||||||
{{if $d.PortfolioHoldings}}
|
{{if $d.PortfolioHoldings}}
|
||||||
<div style="display:flex; flex-direction:column;">
|
<div style="display:flex; flex-direction:column;">
|
||||||
@ -145,7 +145,7 @@
|
|||||||
<div style="display:flex; align-items:center; justify-content:space-between; padding:9px 0; border-bottom:1px solid var(--border);">
|
<div style="display:flex; align-items:center; justify-content:space-between; padding:9px 0; border-bottom:1px solid var(--border);">
|
||||||
<div>
|
<div>
|
||||||
<div style="font-size:13px; font-weight:600; color:var(--text);">{{.Name}}</div>
|
<div style="font-size:13px; font-weight:600; color:var(--text);">{{.Name}}</div>
|
||||||
<div style="font-size:11px; color:var(--text3);">{{printf "%.4f" .SharesOwned}} shares</div>
|
<div style="font-size:11px; color:var(--text3);">{{printf "%.4f" .SharesOwned}} {{$d.T.Get "dashboard.stocks.shares_label"}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align:right;">
|
<div style="text-align:right;">
|
||||||
{{if $d.PortfolioPricesAvailable}}
|
{{if $d.PortfolioPricesAvailable}}
|
||||||
@ -155,20 +155,20 @@
|
|||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div style="font-size:13px; font-weight:500; color:var(--text);">€{{cents .TotalCostCents}}</div>
|
<div style="font-size:13px; font-weight:500; color:var(--text);">€{{cents .TotalCostCents}}</div>
|
||||||
<div style="font-size:11px; color:var(--text3);">cost basis</div>
|
<div style="font-size:11px; color:var(--text3);">{{$d.T.Get "dashboard.stocks.cost_basis"}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:12px 0 0 0;">
|
<div style="display:flex; justify-content:space-between; align-items:center; padding:12px 0 0 0;">
|
||||||
<span style="font-size:13px; font-weight:500; color:var(--text);">Total{{if not $d.PortfolioPricesAvailable}} invested{{end}}</span>
|
<span style="font-size:13px; font-weight:500; color:var(--text);">{{$d.T.Get "dashboard.stocks.total_label"}}{{if not $d.PortfolioPricesAvailable}} {{$d.T.Get "dashboard.stocks.total_invested"}}{{end}}</span>
|
||||||
<span class="animate-counter" style="font-size:15px; font-weight:600; color:var(--text);"
|
<span class="animate-counter" style="font-size:15px; font-weight:600; color:var(--text);"
|
||||||
data-target="{{$d.PortfolioValueCents}}" data-prefix="€">€0.00</span>
|
data-target="{{$d.PortfolioValueCents}}" data-prefix="€">€0.00</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="empty-state" style="padding:24px;">
|
<div class="empty-state" style="padding:24px;">
|
||||||
<p>No holdings yet.<br><a href="/import" style="color:var(--accent);">Import trades →</a></p>
|
<p>{{$d.T.Get "dashboard.stocks.no_holdings_msg"}}<br><a href="/import" style="color:var(--accent);">{{$d.T.Get "dashboard.stocks.import_link"}}</a></p>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
@ -181,8 +181,8 @@
|
|||||||
{{if $d.CategoryBudgets}}
|
{{if $d.CategoryBudgets}}
|
||||||
<div class="card animate-on-scroll">
|
<div class="card animate-on-scroll">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
||||||
<h2>Budget health</h2>
|
<h2>{{$d.T.Get "dashboard.budget_health.section_title"}}</h2>
|
||||||
<a href="/categories" style="font-size:12px; color:var(--text3);">→ categories</a>
|
<a href="/categories" style="font-size:12px; color:var(--text3);">{{$d.T.Get "dashboard.budget_health.categories_link"}}</a>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex; flex-direction:column; gap:10px;">
|
<div style="display:flex; flex-direction:column; gap:10px;">
|
||||||
{{range $cat, $budget := $d.CategoryBudgets}}
|
{{range $cat, $budget := $d.CategoryBudgets}}
|
||||||
@ -214,8 +214,8 @@
|
|||||||
|
|
||||||
<div class="card animate-on-scroll">
|
<div class="card animate-on-scroll">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
||||||
<h2>Recent activity</h2>
|
<h2>{{$d.T.Get "dashboard.recent.section_title"}}</h2>
|
||||||
<a href="/transactions" style="font-size:12px; color:var(--text3);">→ all transactions</a>
|
<a href="/transactions" style="font-size:12px; color:var(--text3);">{{$d.T.Get "dashboard.recent.all_txns_link"}}</a>
|
||||||
</div>
|
</div>
|
||||||
{{if $d.RecentTxns}}
|
{{if $d.RecentTxns}}
|
||||||
<div style="display:flex; flex-direction:column;">
|
<div style="display:flex; flex-direction:column;">
|
||||||
@ -239,7 +239,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="empty-state" style="padding:24px;">
|
<div class="empty-state" style="padding:24px;">
|
||||||
No transactions yet. <a href="/import" style="color:var(--accent);">Import some!</a>
|
{{$d.T.Get "dashboard.recent.no_txns_msg"}} <a href="/import" style="color:var(--accent);">{{$d.T.Get "dashboard.recent.import_link"}}</a>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
@ -249,8 +249,8 @@
|
|||||||
{{if $d.DashGoals}}
|
{{if $d.DashGoals}}
|
||||||
<div class="card animate-on-scroll" style="margin-top:16px;">
|
<div class="card animate-on-scroll" style="margin-top:16px;">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
||||||
<h2>Committed goals</h2>
|
<h2>{{$d.T.Get "dashboard.goals.section_title"}}</h2>
|
||||||
<a href="/goals" style="font-size:12px; color:var(--text3);">→ all goals</a>
|
<a href="/goals" style="font-size:12px; color:var(--text3);">{{$d.T.Get "dashboard.goals.all_goals_link"}}</a>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex; flex-direction:column; gap:14px;">
|
<div style="display:flex; flex-direction:column; gap:14px;">
|
||||||
{{range $d.DashGoals}}
|
{{range $d.DashGoals}}
|
||||||
@ -261,7 +261,7 @@
|
|||||||
<span style="font-size:13px; font-weight:500; color:var(--text);">{{.Name}}</span>
|
<span style="font-size:13px; font-weight:500; color:var(--text);">{{.Name}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex; align-items:center; gap:12px;">
|
<div style="display:flex; align-items:center; gap:12px;">
|
||||||
<span style="font-size:12px; color:var(--text3);">{{.MonthsLeft}}mo left</span>
|
<span style="font-size:12px; color:var(--text3);">{{.MonthsLeft}}{{$d.T.Get "dashboard.goals.months_left"}}</span>
|
||||||
<span style="font-size:12px; font-weight:600; color:{{if .Feasible}}var(--green){{else}}var(--red){{end}};">{{.ProgressPct}}%</span>
|
<span style="font-size:12px; font-weight:600; color:{{if .Feasible}}var(--green){{else}}var(--red){{end}};">{{.ProgressPct}}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -272,7 +272,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div style="display:flex; justify-content:space-between; margin-top:4px; font-size:11px; color:var(--text3);">
|
<div style="display:flex; justify-content:space-between; margin-top:4px; font-size:11px; color:var(--text3);">
|
||||||
<span>€{{cents .SavedCents}} of €{{cents .TargetCents}}</span>
|
<span>€{{cents .SavedCents}} of €{{cents .TargetCents}}</span>
|
||||||
<span style="color:{{if .Feasible}}var(--green){{else}}var(--red){{end}};">€{{cents .MonthlyCents}}/mo needed</span>
|
<span style="color:{{if .Feasible}}var(--green){{else}}var(--red){{end}};">€{{cents .MonthlyCents}}{{$d.T.Get "dashboard.goals.per_month_needed"}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
@ -283,8 +283,8 @@
|
|||||||
{{if $d.RecurringExpenses}}
|
{{if $d.RecurringExpenses}}
|
||||||
<div class="card animate-on-scroll" style="margin-top:16px;">
|
<div class="card animate-on-scroll" style="margin-top:16px;">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
||||||
<h2>Fixed costs</h2>
|
<h2>{{$d.T.Get "dashboard.fixed_costs.section_title"}}</h2>
|
||||||
<span style="font-size:11px; color:var(--text3);">auto-detected · 3-month average</span>
|
<span style="font-size:11px; color:var(--text3);">{{$d.T.Get "dashboard.fixed_costs.auto_detected"}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex; flex-direction:column;">
|
<div style="display:flex; flex-direction:column;">
|
||||||
{{range $d.RecurringExpenses}}
|
{{range $d.RecurringExpenses}}
|
||||||
@ -298,17 +298,17 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
<div>
|
<div>
|
||||||
<div style="font-size:13px; font-weight:500; color:var(--text);">{{.Category}}</div>
|
<div style="font-size:13px; font-weight:500; color:var(--text);">{{.Category}}</div>
|
||||||
<div style="font-size:11px; color:var(--text3);">{{if .IsGoal}}committed goal{{else}}recurring expense{{end}}</div>
|
<div style="font-size:11px; color:var(--text3);">{{if .IsGoal}}{{$d.T.Get "dashboard.fixed_costs.committed_goal"}}{{else}}{{$d.T.Get "dashboard.fixed_costs.recurring_expense"}}{{end}}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align:right;">
|
<div style="text-align:right;">
|
||||||
<div style="font-size:14px; font-weight:600; color:var(--red);">− €{{cents .MonthlyCents}}</div>
|
<div style="font-size:14px; font-weight:600; color:var(--red);">− €{{cents .MonthlyCents}}</div>
|
||||||
<div style="font-size:11px; color:var(--text3);">/ month</div>
|
<div style="font-size:11px; color:var(--text3);">{{$d.T.Get "dashboard.fixed_costs.per_month"}}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:12px 0 0 0;">
|
<div style="display:flex; justify-content:space-between; align-items:center; padding:12px 0 0 0;">
|
||||||
<span style="font-size:13px; font-weight:500; color:var(--text);">Total committed</span>
|
<span style="font-size:13px; font-weight:500; color:var(--text);">{{$d.T.Get "dashboard.fixed_costs.total_committed"}}</span>
|
||||||
<span class="animate-counter" style="font-size:15px; font-weight:600; color:var(--red);"
|
<span class="animate-counter" style="font-size:15px; font-weight:600; color:var(--red);"
|
||||||
data-target="{{$d.TotalCommittedCents}}" data-prefix="€">€0</span>
|
data-target="{{$d.TotalCommittedCents}}" data-prefix="€">€0</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
{{$d := .}}
|
{{$d := .}}
|
||||||
|
|
||||||
<div style="display:flex; align-items:baseline; justify-content:space-between; margin-bottom:20px; flex-wrap:wrap; gap:8px;">
|
<div style="display:flex; align-items:baseline; justify-content:space-between; margin-bottom:20px; flex-wrap:wrap; gap:8px;">
|
||||||
<h1>Goals</h1>
|
<h1>{{$d.T.Get "goals.title"}}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab bar -->
|
<!-- Tab bar -->
|
||||||
@ -10,12 +10,12 @@
|
|||||||
<a href="/goals?tab=goals"
|
<a href="/goals?tab=goals"
|
||||||
style="padding:8px 16px; font-size:14px; font-weight:500; border-radius:6px 6px 0 0; text-decoration:none;
|
style="padding:8px 16px; font-size:14px; font-weight:500; border-radius:6px 6px 0 0; text-decoration:none;
|
||||||
{{if eq $d.Tab "goals"}}background:var(--bg2); color:var(--text1); border:1px solid var(--border); border-bottom:1px solid var(--bg2); margin-bottom:-1px;{{else}}color:var(--text3);{{end}}">
|
{{if eq $d.Tab "goals"}}background:var(--bg2); color:var(--text1); border:1px solid var(--border); border-bottom:1px solid var(--bg2); margin-bottom:-1px;{{else}}color:var(--text3);{{end}}">
|
||||||
Committed goals
|
{{$d.T.Get "goals.tab_committed"}}
|
||||||
</a>
|
</a>
|
||||||
<a href="/goals?tab=planner"
|
<a href="/goals?tab=planner"
|
||||||
style="padding:8px 16px; font-size:14px; font-weight:500; border-radius:6px 6px 0 0; text-decoration:none;
|
style="padding:8px 16px; font-size:14px; font-weight:500; border-radius:6px 6px 0 0; text-decoration:none;
|
||||||
{{if eq $d.Tab "planner"}}background:var(--bg2); color:var(--text1); border:1px solid var(--border); border-bottom:1px solid var(--bg2); margin-bottom:-1px;{{else}}color:var(--text3);{{end}}">
|
{{if eq $d.Tab "planner"}}background:var(--bg2); color:var(--text1); border:1px solid var(--border); border-bottom:1px solid var(--bg2); margin-bottom:-1px;{{else}}color:var(--text3);{{end}}">
|
||||||
Goal Planner
|
{{$d.T.Get "goals.tab_planner"}}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -32,26 +32,26 @@
|
|||||||
{{if $d.AvgMonthlySavings}}
|
{{if $d.AvgMonthlySavings}}
|
||||||
<div style="display:flex; gap:10px; margin-bottom:20px; flex-wrap:wrap;">
|
<div style="display:flex; gap:10px; margin-bottom:20px; flex-wrap:wrap;">
|
||||||
<div class="card value-card animate-on-scroll" style="flex:1; min-width:160px;">
|
<div class="card value-card animate-on-scroll" style="flex:1; min-width:160px;">
|
||||||
<h2>Avg monthly savings</h2>
|
<h2>{{$d.T.Get "goals.summary_cards.avg_monthly_savings"}}</h2>
|
||||||
<div class="value positive animate-counter" data-target="{{$d.AvgMonthlySavings}}" data-prefix="€">€0</div>
|
<div class="value positive animate-counter" data-target="{{$d.AvgMonthlySavings}}" data-prefix="€">€0</div>
|
||||||
<p style="font-size:12px; color:var(--text3); margin-top:4px;">last 3 months</p>
|
<p style="font-size:12px; color:var(--text3); margin-top:4px;">{{$d.T.Get "goals.summary_cards.last_3_months"}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card value-card animate-on-scroll" style="flex:1; min-width:160px;">
|
<div class="card value-card animate-on-scroll" style="flex:1; min-width:160px;">
|
||||||
<h2>Disposable income</h2>
|
<h2>{{$d.T.Get "goals.summary_cards.disposable_income"}}</h2>
|
||||||
<div class="value animate-counter" data-target="{{$d.DisposableIncome}}" data-prefix="€" style="color:var(--text);">€0</div>
|
<div class="value animate-counter" data-target="{{$d.DisposableIncome}}" data-prefix="€" style="color:var(--text);">€0</div>
|
||||||
<p style="font-size:12px; color:var(--text3); margin-top:4px;">before goals</p>
|
<p style="font-size:12px; color:var(--text3); margin-top:4px;">{{$d.T.Get "goals.summary_cards.before_goals"}}</p>
|
||||||
</div>
|
</div>
|
||||||
{{if $d.CommittedMonthlyCents}}
|
{{if $d.CommittedMonthlyCents}}
|
||||||
<div class="card value-card animate-on-scroll" style="flex:1; min-width:160px;">
|
<div class="card value-card animate-on-scroll" style="flex:1; min-width:160px;">
|
||||||
<h2>Reserved for goals</h2>
|
<h2>{{$d.T.Get "goals.summary_cards.reserved_for_goals"}}</h2>
|
||||||
<div class="value negative animate-counter" data-target="{{$d.CommittedMonthlyCents}}" data-prefix="€">€0</div>
|
<div class="value negative animate-counter" data-target="{{$d.CommittedMonthlyCents}}" data-prefix="€">€0</div>
|
||||||
<p style="font-size:12px; color:var(--text3); margin-top:4px;">per month</p>
|
<p style="font-size:12px; color:var(--text3); margin-top:4px;">{{$d.T.Get "goals.summary_cards.per_month"}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card value-card animate-on-scroll" style="flex:1; min-width:160px;">
|
<div class="card value-card animate-on-scroll" style="flex:1; min-width:160px;">
|
||||||
<h2>Free to spend</h2>
|
<h2>{{$d.T.Get "goals.summary_cards.free_to_spend"}}</h2>
|
||||||
<div class="value animate-counter {{if lt $d.RemainingDisposable 0}}negative{{else}}positive{{end}}"
|
<div class="value animate-counter {{if lt $d.RemainingDisposable 0}}negative{{else}}positive{{end}}"
|
||||||
data-target="{{$d.RemainingDisposable}}" data-prefix="€">€0</div>
|
data-target="{{$d.RemainingDisposable}}" data-prefix="€">€0</div>
|
||||||
<p style="font-size:12px; color:var(--text3); margin-top:4px;">after goals</p>
|
<p style="font-size:12px; color:var(--text3); margin-top:4px;">{{$d.T.Get "goals.summary_cards.after_goals"}}</p>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
@ -69,14 +69,14 @@
|
|||||||
<div>
|
<div>
|
||||||
<div style="font-size:15px; font-weight:600; color:var(--text);">{{.Name}}</div>
|
<div style="font-size:15px; font-weight:600; color:var(--text);">{{.Name}}</div>
|
||||||
<div style="font-size:11px; color:var(--text3); text-transform:uppercase; letter-spacing:.4px;">
|
<div style="font-size:11px; color:var(--text3); text-transform:uppercase; letter-spacing:.4px;">
|
||||||
{{if eq .Type "once"}}One-off purchase{{else if eq .Type "deposit"}}Deposit / down-payment{{else if eq .Type "emergency"}}Emergency fund{{else}}Recurring investment{{end}}
|
{{if eq .Type "once"}}{{$d.T.Get "goals.goal_card.type_once"}}{{else if eq .Type "deposit"}}{{$d.T.Get "goals.goal_card.type_deposit"}}{{else if eq .Type "emergency"}}{{$d.T.Get "goals.goal_card.type_emergency"}}{{else}}{{$d.T.Get "goals.goal_card.type_recurring"}}{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-top:12px;">
|
<div style="margin-top:12px;">
|
||||||
<div style="display:flex; justify-content:space-between; margin-bottom:5px;">
|
<div style="display:flex; justify-content:space-between; margin-bottom:5px;">
|
||||||
<span style="font-size:12px; color:var(--text2);">€{{cents .SavedCents}} saved of €{{cents .TargetCents}}</span>
|
<span style="font-size:12px; color:var(--text2);">€{{cents .SavedCents}} {{$d.T.Get "goals.goal_card.saved_of"}} €{{cents .TargetCents}}</span>
|
||||||
<span style="font-size:12px; color:var(--text2);">{{.ProgressPct}}%</span>
|
<span style="font-size:12px; color:var(--text2);">{{.ProgressPct}}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="background:var(--bg3); border-radius:99px; height:6px; overflow:hidden;">
|
<div style="background:var(--bg3); border-radius:99px; height:6px; overflow:hidden;">
|
||||||
@ -89,16 +89,16 @@
|
|||||||
|
|
||||||
<div style="display:flex; gap:24px; flex-wrap:wrap; align-items:flex-start;">
|
<div style="display:flex; gap:24px; flex-wrap:wrap; align-items:flex-start;">
|
||||||
<div style="text-align:center;">
|
<div style="text-align:center;">
|
||||||
<div style="font-size:11px; color:var(--text3); margin-bottom:3px;">Need per month</div>
|
<div style="font-size:11px; color:var(--text3); margin-bottom:3px;">{{$d.T.Get "goals.goal_card.need_per_month"}}</div>
|
||||||
<div class="animate-counter" style="font-size:18px; font-weight:600; color:var(--text);"
|
<div class="animate-counter" style="font-size:18px; font-weight:600; color:var(--text);"
|
||||||
data-target="{{.MonthlyCents}}" data-prefix="€">€0</div>
|
data-target="{{.MonthlyCents}}" data-prefix="€">€0</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align:center;">
|
<div style="text-align:center;">
|
||||||
<div style="font-size:11px; color:var(--text3); margin-bottom:3px;">Months left</div>
|
<div style="font-size:11px; color:var(--text3); margin-bottom:3px;">{{$d.T.Get "goals.goal_card.months_left"}}</div>
|
||||||
<div style="font-size:18px; font-weight:600; color:var(--text);">{{.MonthsLeft}}</div>
|
<div style="font-size:18px; font-weight:600; color:var(--text);">{{.MonthsLeft}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align:center;">
|
<div style="text-align:center;">
|
||||||
<div style="font-size:11px; color:var(--text3); margin-bottom:3px;">At current rate</div>
|
<div style="font-size:11px; color:var(--text3); margin-bottom:3px;">{{$d.T.Get "goals.goal_card.at_current_rate"}}</div>
|
||||||
{{if gt .MonthsAtCurrentRate 0}}
|
{{if gt .MonthsAtCurrentRate 0}}
|
||||||
<div style="font-size:18px; font-weight:600;
|
<div style="font-size:18px; font-weight:600;
|
||||||
{{if .Feasible}}color:var(--green){{else}}color:var(--red){{end}};">
|
{{if .Feasible}}color:var(--green){{else}}color:var(--red){{end}};">
|
||||||
@ -109,7 +109,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align:center;">
|
<div style="text-align:center;">
|
||||||
<div style="font-size:11px; color:var(--text3); margin-bottom:3px;">Disposable after</div>
|
<div style="font-size:11px; color:var(--text3); margin-bottom:3px;">{{$d.T.Get "goals.goal_card.disposable_after"}}</div>
|
||||||
<div class="animate-counter" style="font-size:18px; font-weight:600;
|
<div class="animate-counter" style="font-size:18px; font-weight:600;
|
||||||
{{if ge .ImpactOnDisposable 0}}color:var(--green){{else}}color:var(--red){{end}};"
|
{{if ge .ImpactOnDisposable 0}}color:var(--green){{else}}color:var(--red){{end}};"
|
||||||
data-target="{{.ImpactOnDisposable}}" data-prefix="€">€0</div>
|
data-target="{{.ImpactOnDisposable}}" data-prefix="€">€0</div>
|
||||||
@ -135,7 +135,7 @@
|
|||||||
<input type="hidden" name="id" value="{{.ID}}">
|
<input type="hidden" name="id" value="{{.ID}}">
|
||||||
<input type="hidden" name="months" value="{{.MonthsAtCurrentRate}}">
|
<input type="hidden" name="months" value="{{.MonthsAtCurrentRate}}">
|
||||||
<button type="submit" class="btn btn-outline btn-sm" style="color:var(--red); border-color:var(--red)44; white-space:nowrap;">
|
<button type="submit" class="btn btn-outline btn-sm" style="color:var(--red); border-color:var(--red)44; white-space:nowrap;">
|
||||||
Adjust deadline →
|
{{$d.T.Get "goals.goal_card.btn_adjust_deadline"}}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
@ -148,17 +148,17 @@
|
|||||||
<input type="hidden" name="id" value="{{.ID}}">
|
<input type="hidden" name="id" value="{{.ID}}">
|
||||||
{{if .Committed}}
|
{{if .Committed}}
|
||||||
<button type="submit" class="btn btn-outline btn-sm" style="color:var(--green); border-color:var(--green)55;">
|
<button type="submit" class="btn btn-outline btn-sm" style="color:var(--green); border-color:var(--green)55;">
|
||||||
✓ Committed — click to uncommit
|
{{$d.T.Get "goals.goal_card.btn_committed"}}
|
||||||
</button>
|
</button>
|
||||||
{{else}}
|
{{else}}
|
||||||
<button type="submit" class="btn btn-primary btn-sm">Commit to this goal</button>
|
<button type="submit" class="btn btn-primary btn-sm">{{$d.T.Get "goals.goal_card.btn_commit"}}</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/goals">
|
<form method="POST" action="/goals">
|
||||||
<input type="hidden" name="action" value="delete">
|
<input type="hidden" name="action" value="delete">
|
||||||
<input type="hidden" name="id" value="{{.ID}}">
|
<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;"
|
<button type="submit" class="btn btn-outline btn-sm" style="color:var(--red); border-color:var(--red)33;"
|
||||||
onclick="return confirm('Remove this goal?')">Remove</button>
|
onclick="return confirm('{{$d.T.Get "goals.goal_card.confirm_remove"}}')">{{$d.T.Get "goals.goal_card.btn_remove"}}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -168,9 +168,9 @@
|
|||||||
{{else}}
|
{{else}}
|
||||||
<div class="card empty-state animate-on-scroll">
|
<div class="card empty-state animate-on-scroll">
|
||||||
<div style="font-size:48px; margin-bottom:16px;">🎯</div>
|
<div style="font-size:48px; margin-bottom:16px;">🎯</div>
|
||||||
<h3>No goals yet</h3>
|
<h3>{{$d.T.Get "goals.empty.title"}}</h3>
|
||||||
<p style="margin-bottom:20px;">Use the <strong>Goal Planner</strong> tab to simulate a goal and save it here.</p>
|
<p style="margin-bottom:20px;">{{$d.T.Get "goals.empty.desc"}}</p>
|
||||||
<a href="/goals?tab=planner" class="btn btn-primary">Open Goal Planner →</a>
|
<a href="/goals?tab=planner" class="btn btn-primary">{{$d.T.Get "goals.empty.btn_open_planner"}}</a>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
@ -181,19 +181,19 @@
|
|||||||
|
|
||||||
<!-- Type selector -->
|
<!-- Type selector -->
|
||||||
<div class="card animate-on-scroll" style="margin-bottom:20px;">
|
<div class="card animate-on-scroll" style="margin-bottom:20px;">
|
||||||
<h2 style="margin-bottom:14px;">What kind of goal?</h2>
|
<h2 style="margin-bottom:14px;">{{$d.T.Get "goals.planner.what_kind"}}</h2>
|
||||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
|
||||||
<a href="/goals?tab=planner&planner_type=purchase"
|
<a href="/goals?tab=planner&planner_type=purchase"
|
||||||
style="display:block; padding:16px 18px; border-radius:var(--radius); border:2px solid {{if eq $d.PlannerType "purchase"}}var(--accent){{else}}var(--border){{end}}; text-decoration:none; transition:border-color 0.15s;">
|
style="display:block; padding:16px 18px; border-radius:var(--radius); border:2px solid {{if eq $d.PlannerType "purchase"}}var(--accent){{else}}var(--border){{end}}; text-decoration:none; transition:border-color 0.15s;">
|
||||||
<div style="font-size:22px; margin-bottom:8px;">🛒</div>
|
<div style="font-size:22px; margin-bottom:8px;">🛒</div>
|
||||||
<div style="font-weight:600; font-size:14px; color:var(--text1); margin-bottom:4px;">Save for a purchase</div>
|
<div style="font-weight:600; font-size:14px; color:var(--text1); margin-bottom:4px;">{{$d.T.Get "goals.planner.purchase_title"}}</div>
|
||||||
<div style="font-size:12px; color:var(--text3);">Car, trip, gadget, fund — save up to a target by a date.</div>
|
<div style="font-size:12px; color:var(--text3);">{{$d.T.Get "goals.planner.purchase_desc"}}</div>
|
||||||
</a>
|
</a>
|
||||||
<a href="/goals?tab=planner&planner_type=transition"
|
<a href="/goals?tab=planner&planner_type=transition"
|
||||||
style="display:block; padding:16px 18px; border-radius:var(--radius); border:2px solid {{if eq $d.PlannerType "transition"}}var(--accent){{else}}var(--border){{end}}; text-decoration:none; transition:border-color 0.15s;">
|
style="display:block; padding:16px 18px; border-radius:var(--radius); border:2px solid {{if eq $d.PlannerType "transition"}}var(--accent){{else}}var(--border){{end}}; text-decoration:none; transition:border-color 0.15s;">
|
||||||
<div style="font-size:22px; margin-bottom:8px;">🔄</div>
|
<div style="font-size:22px; margin-bottom:8px;">🔄</div>
|
||||||
<div style="font-weight:600; font-size:14px; color:var(--text1); margin-bottom:4px;">Sell & upgrade</div>
|
<div style="font-weight:600; font-size:14px; color:var(--text1); margin-bottom:4px;">{{$d.T.Get "goals.planner.transition_title"}}</div>
|
||||||
<div style="font-size:12px; color:var(--text3);">Own an asset with a loan, acquire something new, sell the old to fund it.</div>
|
<div style="font-size:12px; color:var(--text3);">{{$d.T.Get "goals.planner.transition_desc"}}</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -201,7 +201,7 @@
|
|||||||
{{if eq $d.PlannerType "purchase"}}
|
{{if eq $d.PlannerType "purchase"}}
|
||||||
<!-- ── PURCHASE FORM ──────────────────────────────────────────────────────── -->
|
<!-- ── PURCHASE FORM ──────────────────────────────────────────────────────── -->
|
||||||
<div class="card animate-on-scroll" style="margin-bottom:20px;">
|
<div class="card animate-on-scroll" style="margin-bottom:20px;">
|
||||||
<h2 style="margin-bottom:16px;">Your purchase goal</h2>
|
<h2 style="margin-bottom:16px;">{{$d.T.Get "goals.planner.purchase.form_title"}}</h2>
|
||||||
<form method="GET" action="/goals">
|
<form method="GET" action="/goals">
|
||||||
<input type="hidden" name="tab" value="planner">
|
<input type="hidden" name="tab" value="planner">
|
||||||
<input type="hidden" name="planner_type" value="purchase">
|
<input type="hidden" name="planner_type" value="purchase">
|
||||||
@ -209,38 +209,38 @@
|
|||||||
<div style="display:flex; flex-direction:column; gap:14px; margin-bottom:16px;">
|
<div style="display:flex; flex-direction:column; gap:14px; margin-bottom:16px;">
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">Goal name</label>
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "goals.planner.purchase.label_name"}}</label>
|
||||||
<input name="name" placeholder="e.g. New car, Europe trip, Emergency fund…"
|
<input name="name" placeholder="{{$d.T.Get "goals.planner.purchase.placeholder_name"}}"
|
||||||
value="{{if $d.HasPurchaseResult}}{{$pr.Name}}{{end}}"
|
value="{{if $d.HasPurchaseResult}}{{$pr.Name}}{{end}}"
|
||||||
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
|
||||||
<div>
|
<div>
|
||||||
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">Target amount (€)</label>
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "goals.planner.purchase.label_target"}}</label>
|
||||||
<input type="number" name="target" min="0" step="100"
|
<input type="number" name="target" min="0" step="100"
|
||||||
value="{{if $d.HasPurchaseResult}}{{div $pr.TargetCents 100}}{{end}}"
|
value="{{if $d.HasPurchaseResult}}{{div $pr.TargetCents 100}}{{end}}"
|
||||||
placeholder="e.g. 12000"
|
placeholder="{{$d.T.Get "goals.planner.purchase.placeholder_target"}}"
|
||||||
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">Monthly savings (€)</label>
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "goals.planner.purchase.label_monthly_savings"}}</label>
|
||||||
<input type="number" name="monthly_savings" min="0" step="50"
|
<input type="number" name="monthly_savings" min="0" step="50"
|
||||||
value="{{if $d.HasPurchaseResult}}{{div $pr.MonthlySavingsCents 100}}{{end}}"
|
value="{{if $d.HasPurchaseResult}}{{div $pr.MonthlySavingsCents 100}}{{end}}"
|
||||||
placeholder="e.g. 400"
|
placeholder="{{$d.T.Get "goals.planner.purchase.placeholder_monthly"}}"
|
||||||
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">Target date (optional — leave blank to see when you'll get there)</label>
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "goals.planner.purchase.label_deadline"}}</label>
|
||||||
<input type="month" name="deadline"
|
<input type="month" name="deadline"
|
||||||
value="{{if and $d.HasPurchaseResult $pr.HasDeadline}}{{$pr.DeadlineDate.Format "2006-01"}}{{end}}"
|
value="{{if and $d.HasPurchaseResult $pr.HasDeadline}}{{$pr.DeadlineDate.Format "2006-01"}}{{end}}"
|
||||||
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary" style="width:100%;">Calculate →</button>
|
<button type="submit" class="btn btn-primary" style="width:100%;">{{$d.T.Get "goals.planner.purchase.btn_calculate"}}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -248,29 +248,29 @@
|
|||||||
<!-- Purchase result -->
|
<!-- Purchase result -->
|
||||||
<div class="grid" style="margin-bottom:20px;">
|
<div class="grid" style="margin-bottom:20px;">
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>At your savings rate</h2>
|
<h2>{{$d.T.Get "goals.planner.purchase_result.card_at_rate"}}</h2>
|
||||||
{{if gt $pr.MonthsNeeded 0}}
|
{{if gt $pr.MonthsNeeded 0}}
|
||||||
<div class="value positive">{{$pr.YearsNeeded}}y {{$pr.RemMonths}}m</div>
|
<div class="value positive">{{$pr.YearsNeeded}}y {{$pr.RemMonths}}m</div>
|
||||||
<p style="font-size:12px; color:var(--text3); margin-top:6px;">reach {{dateShort $pr.ReachDate}}</p>
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "goals.planner.purchase_result.reach_label"}} {{dateShort $pr.ReachDate}}</p>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="value" style="color:var(--text3); font-size:18px;">—</div>
|
<div class="value" style="color:var(--text3); font-size:18px;">—</div>
|
||||||
<p style="font-size:12px; color:var(--text3); margin-top:6px;">enter monthly savings</p>
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "goals.planner.purchase_result.enter_monthly"}}</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Monthly needed</h2>
|
<h2>{{$d.T.Get "goals.planner.purchase_result.card_monthly_needed"}}</h2>
|
||||||
{{if $pr.HasDeadline}}
|
{{if $pr.HasDeadline}}
|
||||||
<div class="value {{if $pr.Feasible}}positive{{else}}negative{{end}}">€{{cents $pr.MonthlyNeededForDeadline}}</div>
|
<div class="value {{if $pr.Feasible}}positive{{else}}negative{{end}}">€{{cents $pr.MonthlyNeededForDeadline}}</div>
|
||||||
<p style="font-size:12px; color:var(--text3); margin-top:6px;">to hit {{dateShort $pr.DeadlineDate}}</p>
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "goals.planner.purchase_result.to_hit_deadline"}} {{dateShort $pr.DeadlineDate}}</p>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="value" style="color:var(--text3); font-size:18px;">—</div>
|
<div class="value" style="color:var(--text3); font-size:18px;">—</div>
|
||||||
<p style="font-size:12px; color:var(--text3); margin-top:6px;">set a target date above</p>
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "goals.planner.purchase_result.set_target_date"}}</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Target</h2>
|
<h2>{{$d.T.Get "goals.planner.purchase_result.card_target"}}</h2>
|
||||||
<div class="value positive">€{{cents $pr.TargetCents}}</div>
|
<div class="value positive">€{{cents $pr.TargetCents}}</div>
|
||||||
<p style="font-size:12px; color:var(--text3); margin-top:6px;">your goal amount</p>
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "goals.planner.purchase_result.your_goal_amount"}}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -289,18 +289,18 @@
|
|||||||
|
|
||||||
<!-- Save as goal -->
|
<!-- Save as goal -->
|
||||||
<div class="card animate-on-scroll">
|
<div class="card animate-on-scroll">
|
||||||
<h2 style="margin-bottom:4px;">Save as a goal</h2>
|
<h2 style="margin-bottom:4px;">{{$d.T.Get "goals.planner.purchase_result.save_as_goal_title"}}</h2>
|
||||||
<p style="font-size:13px; color:var(--text3); margin-bottom:14px;">Adds this to your Goals tab so you can commit to it.</p>
|
<p style="font-size:13px; color:var(--text3); margin-bottom:14px;">{{$d.T.Get "goals.planner.purchase_result.save_as_goal_desc"}}</p>
|
||||||
<form method="POST" action="/goals" style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end;">
|
<form method="POST" action="/goals" style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end;">
|
||||||
<input type="hidden" name="type" value="once">
|
<input type="hidden" name="type" value="once">
|
||||||
<input type="hidden" name="target_euros" value="{{div $pr.TargetCents 100}}">
|
<input type="hidden" name="target_euros" value="{{div $pr.TargetCents 100}}">
|
||||||
<input type="hidden" name="deadline" value="{{if $pr.HasDeadline}}{{$pr.DeadlineDate.Format "2006-01"}}{{else}}{{$pr.ReachDate.Format "2006-01"}}{{end}}">
|
<input type="hidden" name="deadline" value="{{if $pr.HasDeadline}}{{$pr.DeadlineDate.Format "2006-01"}}{{else}}{{$pr.ReachDate.Format "2006-01"}}{{end}}">
|
||||||
<div style="flex:1; min-width:200px;">
|
<div style="flex:1; min-width:200px;">
|
||||||
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">Goal name</label>
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "goals.planner.purchase_result.label_goal_name"}}</label>
|
||||||
<input name="name" required value="{{$pr.Name}}" placeholder="e.g. New car"
|
<input name="name" required value="{{$pr.Name}}" placeholder="{{$d.T.Get "goals.planner.purchase_result.placeholder_goal_name"}}"
|
||||||
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary" style="white-space:nowrap;">Save goal →</button>
|
<button type="submit" class="btn btn-primary" style="white-space:nowrap;">{{$d.T.Get "goals.planner.purchase_result.btn_save_goal"}}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
@ -308,7 +308,7 @@
|
|||||||
{{else if eq $d.PlannerType "transition"}}
|
{{else if eq $d.PlannerType "transition"}}
|
||||||
<!-- ── TRANSITION FORM ───────────────────────────────────────────────────── -->
|
<!-- ── TRANSITION FORM ───────────────────────────────────────────────────── -->
|
||||||
<div class="card animate-on-scroll" style="margin-bottom:20px;">
|
<div class="card animate-on-scroll" style="margin-bottom:20px;">
|
||||||
<h2 style="margin-bottom:16px;">Your transition scenario</h2>
|
<h2 style="margin-bottom:16px;">{{$d.T.Get "goals.planner.transition.form_title"}}</h2>
|
||||||
<form method="GET" action="/goals">
|
<form method="GET" action="/goals">
|
||||||
<input type="hidden" name="tab" value="planner">
|
<input type="hidden" name="tab" value="planner">
|
||||||
<input type="hidden" name="planner_type" value="transition">
|
<input type="hidden" name="planner_type" value="transition">
|
||||||
@ -317,9 +317,9 @@
|
|||||||
<div class="grid" style="gap:14px; margin-bottom:16px;">
|
<div class="grid" style="gap:14px; margin-bottom:16px;">
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">Current asset (optional)</label>
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "goals.planner.transition.label_current_asset"}}</label>
|
||||||
<select name="property_id" style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px;">
|
<select name="property_id" style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px;">
|
||||||
<option value="">— none selected —</option>
|
<option value="">{{$d.T.Get "goals.planner.transition.option_none_asset"}}</option>
|
||||||
{{range $d.PlanProperties}}
|
{{range $d.PlanProperties}}
|
||||||
<option value="{{.ID}}" {{if and $d.HasPlanResult (eq $d.PlanForm.PropertyID .ID)}}selected{{end}}>{{.Name}} (€{{cents .CurrentValueCents}})</option>
|
<option value="{{.ID}}" {{if and $d.HasPlanResult (eq $d.PlanForm.PropertyID .ID)}}selected{{end}}>{{.Name}} (€{{cents .CurrentValueCents}})</option>
|
||||||
{{end}}
|
{{end}}
|
||||||
@ -327,9 +327,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">Current loan (optional)</label>
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "goals.planner.transition.label_current_loan"}}</label>
|
||||||
<select name="loan_id" style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px;">
|
<select name="loan_id" style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px;">
|
||||||
<option value="">— none selected —</option>
|
<option value="">{{$d.T.Get "goals.planner.transition.option_none_loan"}}</option>
|
||||||
{{range $d.PlanLoans}}
|
{{range $d.PlanLoans}}
|
||||||
<option value="{{.ID}}" {{if and $d.HasPlanResult (eq $d.PlanForm.LoanID .ID)}}selected{{end}}>{{.Name}} (€{{cents .BalanceCents}} left)</option>
|
<option value="{{.ID}}" {{if and $d.HasPlanResult (eq $d.PlanForm.LoanID .ID)}}selected{{end}}>{{.Name}} (€{{cents .BalanceCents}} left)</option>
|
||||||
{{end}}
|
{{end}}
|
||||||
@ -337,60 +337,60 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">New goal cost (€)</label>
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "goals.planner.transition.label_dream_cost"}}</label>
|
||||||
<input type="number" name="dream_cost" min="0" step="1000"
|
<input type="number" name="dream_cost" min="0" step="1000"
|
||||||
value="{{if $d.HasPlanResult}}{{div $d.PlanForm.DreamCostCents 100}}{{end}}"
|
value="{{if $d.HasPlanResult}}{{div $d.PlanForm.DreamCostCents 100}}{{end}}"
|
||||||
placeholder="e.g. 350000"
|
placeholder="{{$d.T.Get "goals.planner.transition.placeholder_dream_cost"}}"
|
||||||
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">Down payment (%)</label>
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "goals.planner.transition.label_down_pct"}}</label>
|
||||||
<input type="number" name="down_pct" min="0" max="100" step="1"
|
<input type="number" name="down_pct" min="0" max="100" step="1"
|
||||||
value="{{if $d.HasPlanResult}}{{round $d.PlanForm.DownPaymentPct}}{{else}}20{{end}}"
|
value="{{if $d.HasPlanResult}}{{round $d.PlanForm.DownPaymentPct}}{{else}}20{{end}}"
|
||||||
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">New loan rate (% annual)</label>
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "goals.planner.transition.label_loan_rate"}}</label>
|
||||||
<input type="number" name="const_rate" min="0" max="30" step="0.1"
|
<input type="number" name="const_rate" min="0" max="30" step="0.1"
|
||||||
value="{{if $d.HasPlanResult}}{{$d.PlanForm.ConstructionRatePct}}{{else}}4.0{{end}}"
|
value="{{if $d.HasPlanResult}}{{$d.PlanForm.ConstructionRatePct}}{{else}}4.0{{end}}"
|
||||||
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">New loan term (years)</label>
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "goals.planner.transition.label_loan_term"}}</label>
|
||||||
<input type="number" name="const_term" min="1" max="40" step="1"
|
<input type="number" name="const_term" min="1" max="40" step="1"
|
||||||
value="{{if $d.HasPlanResult}}{{$d.PlanForm.ConstructionTermYears}}{{else}}30{{end}}"
|
value="{{if $d.HasPlanResult}}{{$d.PlanForm.ConstructionTermYears}}{{else}}30{{end}}"
|
||||||
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">Acquisition / build period (months)</label>
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "goals.planner.transition.label_build_months"}}</label>
|
||||||
<input type="number" name="build_months" min="1" max="60" step="1"
|
<input type="number" name="build_months" min="1" max="60" step="1"
|
||||||
value="{{if $d.HasPlanResult}}{{$d.PlanForm.BuildMonths}}{{else}}18{{end}}"
|
value="{{if $d.HasPlanResult}}{{$d.PlanForm.BuildMonths}}{{else}}18{{end}}"
|
||||||
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">Monthly savings available (€)</label>
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "goals.planner.transition.label_monthly_savings"}}</label>
|
||||||
<input type="number" name="monthly_savings" min="0" step="100"
|
<input type="number" name="monthly_savings" min="0" step="100"
|
||||||
value="{{if $d.HasPlanResult}}{{div $d.PlanForm.MonthlySavingsCents 100}}{{end}}"
|
value="{{if $d.HasPlanResult}}{{div $d.PlanForm.MonthlySavingsCents 100}}{{end}}"
|
||||||
placeholder="e.g. 800"
|
placeholder="{{$d.T.Get "goals.planner.transition.placeholder_savings"}}"
|
||||||
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">Expected sale price of current asset (€)</label>
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "goals.planner.transition.label_sale_price"}}</label>
|
||||||
<input type="number" name="sale_price" min="0" step="1000"
|
<input type="number" name="sale_price" min="0" step="1000"
|
||||||
value="{{if $d.HasPlanResult}}{{div $d.PlanForm.ExpectedSalePriceCents 100}}{{end}}"
|
value="{{if $d.HasPlanResult}}{{div $d.PlanForm.ExpectedSalePriceCents 100}}{{end}}"
|
||||||
placeholder="leave blank to use current value"
|
placeholder="{{$d.T.Get "goals.planner.transition.placeholder_sale_price"}}"
|
||||||
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary" style="width:100%;">Run simulation →</button>
|
<button type="submit" class="btn btn-primary" style="width:100%;">{{$d.T.Get "goals.planner.transition.btn_run"}}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -405,52 +405,52 @@
|
|||||||
<!-- Summary cards -->
|
<!-- Summary cards -->
|
||||||
<div class="grid" style="margin-bottom:20px;">
|
<div class="grid" style="margin-bottom:20px;">
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Total timeline</h2>
|
<h2>{{$d.T.Get "goals.planner.transition_result.card_total_timeline"}}</h2>
|
||||||
<div class="value positive">{{$r.TotalYears}}y {{$r.TotalRemMonths}}m</div>
|
<div class="value positive">{{$r.TotalYears}}y {{$r.TotalRemMonths}}m</div>
|
||||||
<p style="font-size:12px; color:var(--text3); margin-top:6px;">until goal is fully paid off</p>
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "goals.planner.transition_result.until_paid_off"}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Final monthly cost</h2>
|
<h2>{{$d.T.Get "goals.planner.transition_result.card_final_monthly"}}</h2>
|
||||||
<div class="value positive">€{{cents $r.Phase4MonthlyCents}}</div>
|
<div class="value positive">€{{cents $r.Phase4MonthlyCents}}</div>
|
||||||
<p style="font-size:12px; color:var(--text3); margin-top:6px;">after selling current asset</p>
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "goals.planner.transition_result.after_selling"}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Total interest</h2>
|
<h2>{{$d.T.Get "goals.planner.transition_result.card_total_interest"}}</h2>
|
||||||
<div class="value" style="color:var(--red);">€{{cents $r.TotalInterestCents}}</div>
|
<div class="value" style="color:var(--red);">€{{cents $r.TotalInterestCents}}</div>
|
||||||
<p style="font-size:12px; color:var(--text3); margin-top:6px;">across both loans</p>
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "goals.planner.transition_result.across_both_loans"}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Free by</h2>
|
<h2>{{$d.T.Get "goals.planner.transition_result.card_free_by"}}</h2>
|
||||||
<div class="value positive" style="font-size:24px;">{{dateShort $r.FinalDate}}</div>
|
<div class="value positive" style="font-size:24px;">{{dateShort $r.FinalDate}}</div>
|
||||||
<p style="font-size:12px; color:var(--text3); margin-top:6px;">fully paid off</p>
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "goals.planner.transition_result.fully_paid_off"}}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Phase timeline -->
|
<!-- Phase timeline -->
|
||||||
<div class="card animate-on-scroll" style="margin-bottom:20px;">
|
<div class="card animate-on-scroll" style="margin-bottom:20px;">
|
||||||
<h2 style="margin-bottom:20px;">Your roadmap</h2>
|
<h2 style="margin-bottom:20px;">{{$d.T.Get "goals.planner.transition_result.roadmap_title"}}</h2>
|
||||||
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:0; position:relative;">
|
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:0; position:relative;">
|
||||||
<div style="position:absolute; top:20px; left:12.5%; right:12.5%; height:3px; background:var(--border); z-index:0; border-radius:2px;"></div>
|
<div style="position:absolute; top:20px; left:12.5%; right:12.5%; height:3px; background:var(--border); z-index:0; border-radius:2px;"></div>
|
||||||
|
|
||||||
<!-- Phase 1 -->
|
<!-- Phase 1 -->
|
||||||
<div style="text-align:center; position:relative; padding:0 8px;">
|
<div style="text-align:center; position:relative; padding:0 8px;">
|
||||||
<div style="width:40px; height:40px; border-radius:50%; background:var(--accent); color:#fff; display:flex; align-items:center; justify-content:center; font-size:18px; margin:0 auto 12px; position:relative; z-index:1;">1</div>
|
<div style="width:40px; height:40px; border-radius:50%; background:var(--accent); color:#fff; display:flex; align-items:center; justify-content:center; font-size:18px; margin:0 auto 12px; position:relative; z-index:1;">1</div>
|
||||||
<div style="font-weight:600; font-size:13px; color:var(--text1); margin-bottom:6px;">Save down payment</div>
|
<div style="font-weight:600; font-size:13px; color:var(--text1); margin-bottom:6px;">{{$d.T.Get "goals.planner.transition_result.phase1_title"}}</div>
|
||||||
{{if gt $r.Phase1Months 0}}
|
{{if gt $r.Phase1Months 0}}
|
||||||
<div style="font-size:22px; font-weight:500; color:var(--accent); margin-bottom:4px;">{{$r.Phase1Months}}mo</div>
|
<div style="font-size:22px; font-weight:500; color:var(--accent); margin-bottom:4px;">{{$r.Phase1Months}}mo</div>
|
||||||
<div style="font-size:11px; color:var(--text3);">until {{dateShort $r.Phase1EndDate}}</div>
|
<div style="font-size:11px; color:var(--text3);">until {{dateShort $r.Phase1EndDate}}</div>
|
||||||
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
|
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
|
||||||
<div>Target: <strong>€{{cents $r.DownPaymentCents}}</strong></div>
|
<div>{{$d.T.Get "goals.planner.transition_result.phase1_target"}} <strong>€{{cents $r.DownPaymentCents}}</strong></div>
|
||||||
<div>Already have: <strong>€{{cents $r.AlreadyHaveCents}}</strong></div>
|
<div>{{$d.T.Get "goals.planner.transition_result.phase1_already_have"}} <strong>€{{cents $r.AlreadyHaveCents}}</strong></div>
|
||||||
<div>Still need: <strong>€{{cents $r.StillNeededCents}}</strong></div>
|
<div>{{$d.T.Get "goals.planner.transition_result.phase1_still_need"}} <strong>€{{cents $r.StillNeededCents}}</strong></div>
|
||||||
<div>Saving: <strong>€{{cents $r.Form.MonthlySavingsCents}}/mo</strong></div>
|
<div>{{$d.T.Get "goals.planner.transition_result.phase1_saving"}} <strong>€{{cents $r.Form.MonthlySavingsCents}}/mo</strong></div>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div style="font-size:16px; font-weight:600; color:var(--green); margin-bottom:4px;">Ready now!</div>
|
<div style="font-size:16px; font-weight:600; color:var(--green); margin-bottom:4px;">{{$d.T.Get "goals.planner.transition_result.phase1_ready"}}</div>
|
||||||
<div style="font-size:11px; color:var(--text3);">equity covers down payment</div>
|
<div style="font-size:11px; color:var(--text3);">{{$d.T.Get "goals.planner.transition_result.phase1_equity_covers"}}</div>
|
||||||
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
|
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
|
||||||
<div>Down payment: <strong>€{{cents $r.DownPaymentCents}}</strong></div>
|
<div>{{$d.T.Get "goals.planner.transition_result.phase1_down_payment"}} <strong>€{{cents $r.DownPaymentCents}}</strong></div>
|
||||||
<div>Your equity: <strong style="color:var(--green);">€{{cents $r.AlreadyHaveCents}}</strong></div>
|
<div>{{$d.T.Get "goals.planner.transition_result.phase1_your_equity"}} <strong style="color:var(--green);">€{{cents $r.AlreadyHaveCents}}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
@ -458,47 +458,47 @@
|
|||||||
<!-- Phase 2 -->
|
<!-- Phase 2 -->
|
||||||
<div style="text-align:center; position:relative; padding:0 8px;">
|
<div style="text-align:center; position:relative; padding:0 8px;">
|
||||||
<div style="width:40px; height:40px; border-radius:50%; background:#f97316; color:#fff; display:flex; align-items:center; justify-content:center; font-size:18px; margin:0 auto 12px; position:relative; z-index:1;">2</div>
|
<div style="width:40px; height:40px; border-radius:50%; background:#f97316; color:#fff; display:flex; align-items:center; justify-content:center; font-size:18px; margin:0 auto 12px; position:relative; z-index:1;">2</div>
|
||||||
<div style="font-weight:600; font-size:13px; color:var(--text1); margin-bottom:6px;">Acquire / build</div>
|
<div style="font-weight:600; font-size:13px; color:var(--text1); margin-bottom:6px;">{{$d.T.Get "goals.planner.transition_result.phase2_title"}}</div>
|
||||||
<div style="font-size:22px; font-weight:500; color:#f97316; margin-bottom:4px;">{{$r.Phase2Months}}mo</div>
|
<div style="font-size:22px; font-weight:500; color:#f97316; margin-bottom:4px;">{{$r.Phase2Months}}mo</div>
|
||||||
<div style="font-size:11px; color:var(--text3);">until {{dateShort $r.Phase2EndDate}}</div>
|
<div style="font-size:11px; color:var(--text3);">until {{dateShort $r.Phase2EndDate}}</div>
|
||||||
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
|
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
|
||||||
<div>New loan: <strong>€{{cents $r.ConstructionLoanCents}}</strong></div>
|
<div>{{$d.T.Get "goals.planner.transition_result.phase2_new_loan"}} <strong>€{{cents $r.ConstructionLoanCents}}</strong></div>
|
||||||
{{if $r.CurrentLoan}}<div>Existing loan: <strong>€{{cents $r.CurrentMonthlyCents}}/mo</strong></div>{{end}}
|
{{if $r.CurrentLoan}}<div>{{$d.T.Get "goals.planner.transition_result.phase2_existing_loan"}} <strong>€{{cents $r.CurrentMonthlyCents}}/mo</strong></div>{{end}}
|
||||||
<div>New EMI: <strong>€{{cents $r.ConstructionMonthly}}/mo</strong></div>
|
<div>{{$d.T.Get "goals.planner.transition_result.phase2_new_emi"}} <strong>€{{cents $r.ConstructionMonthly}}/mo</strong></div>
|
||||||
<div style="border-top:1px solid var(--border); margin-top:6px; padding-top:6px;">Total burden: <strong style="color:#f97316;">€{{cents $r.Phase2MonthlyCents}}/mo</strong></div>
|
<div style="border-top:1px solid var(--border); margin-top:6px; padding-top:6px;">{{$d.T.Get "goals.planner.transition_result.phase2_total_burden"}} <strong style="color:#f97316;">€{{cents $r.Phase2MonthlyCents}}/mo</strong></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Phase 3 -->
|
<!-- Phase 3 -->
|
||||||
<div style="text-align:center; position:relative; padding:0 8px;">
|
<div style="text-align:center; position:relative; padding:0 8px;">
|
||||||
<div style="width:40px; height:40px; border-radius:50%; background:#14b8a6; color:#fff; display:flex; align-items:center; justify-content:center; font-size:18px; margin:0 auto 12px; position:relative; z-index:1;">3</div>
|
<div style="width:40px; height:40px; border-radius:50%; background:#14b8a6; color:#fff; display:flex; align-items:center; justify-content:center; font-size:18px; margin:0 auto 12px; position:relative; z-index:1;">3</div>
|
||||||
<div style="font-weight:600; font-size:13px; color:var(--text1); margin-bottom:6px;">Sell & transition</div>
|
<div style="font-weight:600; font-size:13px; color:var(--text1); margin-bottom:6px;">{{$d.T.Get "goals.planner.transition_result.phase3_title"}}</div>
|
||||||
<div style="font-size:16px; font-weight:600; color:#14b8a6; margin-bottom:4px;">One-time event</div>
|
<div style="font-size:16px; font-weight:600; color:#14b8a6; margin-bottom:4px;">{{$d.T.Get "goals.planner.transition_result.phase3_one_time"}}</div>
|
||||||
<div style="font-size:11px; color:var(--text3);">after acquisition completes</div>
|
<div style="font-size:11px; color:var(--text3);">{{$d.T.Get "goals.planner.transition_result.phase3_after_acquisition"}}</div>
|
||||||
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
|
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
|
||||||
<div>Sale price: <strong>€{{cents $r.SalePriceCents}}</strong></div>
|
<div>{{$d.T.Get "goals.planner.transition_result.phase3_sale_price"}} <strong>€{{cents $r.SalePriceCents}}</strong></div>
|
||||||
<div>Pay off loan: <strong style="color:var(--red);">-€{{cents $r.MortgagePayoffCents}}</strong></div>
|
<div>{{$d.T.Get "goals.planner.transition_result.phase3_pay_off"}} <strong style="color:var(--red);">-€{{cents $r.MortgagePayoffCents}}</strong></div>
|
||||||
<div style="border-top:1px solid var(--border); margin-top:6px; padding-top:6px;">Net proceeds: <strong style="color:var(--green);">€{{cents $r.NetProceedsCents}}</strong></div>
|
<div style="border-top:1px solid var(--border); margin-top:6px; padding-top:6px;">{{$d.T.Get "goals.planner.transition_result.phase3_net_proceeds"}} <strong style="color:var(--green);">€{{cents $r.NetProceedsCents}}</strong></div>
|
||||||
<div>Applied to new loan</div>
|
<div>{{$d.T.Get "goals.planner.transition_result.phase3_applied"}}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Phase 4 -->
|
<!-- Phase 4 -->
|
||||||
<div style="text-align:center; position:relative; padding:0 8px;">
|
<div style="text-align:center; position:relative; padding:0 8px;">
|
||||||
<div style="width:40px; height:40px; border-radius:50%; background:var(--green); color:#fff; display:flex; align-items:center; justify-content:center; font-size:18px; margin:0 auto 12px; position:relative; z-index:1;">4</div>
|
<div style="width:40px; height:40px; border-radius:50%; background:var(--green); color:#fff; display:flex; align-items:center; justify-content:center; font-size:18px; margin:0 auto 12px; position:relative; z-index:1;">4</div>
|
||||||
<div style="font-weight:600; font-size:13px; color:var(--text1); margin-bottom:6px;">Goal achieved</div>
|
<div style="font-weight:600; font-size:13px; color:var(--text1); margin-bottom:6px;">{{$d.T.Get "goals.planner.transition_result.phase4_title"}}</div>
|
||||||
{{if gt $r.Phase4Months 0}}
|
{{if gt $r.Phase4Months 0}}
|
||||||
<div style="font-size:22px; font-weight:500; color:var(--green); margin-bottom:4px;">{{$r.Phase4Months}}mo</div>
|
<div style="font-size:22px; font-weight:500; color:var(--green); margin-bottom:4px;">{{$r.Phase4Months}}mo</div>
|
||||||
<div style="font-size:11px; color:var(--text3);">paid off {{dateShort $r.Phase4EndDate}}</div>
|
<div style="font-size:11px; color:var(--text3);">paid off {{dateShort $r.Phase4EndDate}}</div>
|
||||||
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
|
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
|
||||||
<div>Remaining loan: <strong>€{{cents $r.RemainingBalanceCents}}</strong></div>
|
<div>{{$d.T.Get "goals.planner.transition_result.phase4_remaining_loan"}} <strong>€{{cents $r.RemainingBalanceCents}}</strong></div>
|
||||||
<div>Monthly: <strong style="color:var(--green);">€{{cents $r.Phase4MonthlyCents}}/mo</strong></div>
|
<div>{{$d.T.Get "goals.planner.transition_result.phase4_monthly"}} <strong style="color:var(--green);">€{{cents $r.Phase4MonthlyCents}}/mo</strong></div>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div style="font-size:16px; font-weight:600; color:var(--green); margin-bottom:4px;">Fully paid!</div>
|
<div style="font-size:16px; font-weight:600; color:var(--green); margin-bottom:4px;">{{$d.T.Get "goals.planner.transition_result.phase4_fully_paid"}}</div>
|
||||||
<div style="font-size:11px; color:var(--text3);">sale proceeds cleared the loan</div>
|
<div style="font-size:11px; color:var(--text3);">{{$d.T.Get "goals.planner.transition_result.phase4_sale_cleared"}}</div>
|
||||||
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
|
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
|
||||||
<div style="color:var(--green); font-weight:600;">No remaining loan!</div>
|
<div style="color:var(--green); font-weight:600;">{{$d.T.Get "goals.planner.transition_result.phase4_no_remaining"}}</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
@ -508,8 +508,8 @@
|
|||||||
<!-- Monthly cost chart -->
|
<!-- Monthly cost chart -->
|
||||||
<div class="card animate-on-scroll" style="margin-bottom:20px;">
|
<div class="card animate-on-scroll" style="margin-bottom:20px;">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
||||||
<h2>Monthly cost over time</h2>
|
<h2>{{$d.T.Get "goals.planner.transition_result.chart_title"}}</h2>
|
||||||
<span style="font-size:11px; color:var(--text3);">what you pay each month</span>
|
<span style="font-size:11px; color:var(--text3);">{{$d.T.Get "goals.planner.transition_result.chart_subtitle"}}</span>
|
||||||
</div>
|
</div>
|
||||||
<canvas id="cost-chart" height="160"></canvas>
|
<canvas id="cost-chart" height="160"></canvas>
|
||||||
</div>
|
</div>
|
||||||
@ -556,18 +556,18 @@
|
|||||||
|
|
||||||
<!-- Save as goal (transition) -->
|
<!-- Save as goal (transition) -->
|
||||||
<div class="card animate-on-scroll">
|
<div class="card animate-on-scroll">
|
||||||
<h2 style="margin-bottom:4px;">Save as a goal</h2>
|
<h2 style="margin-bottom:4px;">{{$d.T.Get "goals.planner.transition_result.save_as_goal_title"}}</h2>
|
||||||
<p style="font-size:13px; color:var(--text3); margin-bottom:14px;">Adds this to your Goals tab so you can commit to it.</p>
|
<p style="font-size:13px; color:var(--text3); margin-bottom:14px;">{{$d.T.Get "goals.planner.transition_result.save_as_goal_desc"}}</p>
|
||||||
<form method="POST" action="/goals" style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end;">
|
<form method="POST" action="/goals" style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end;">
|
||||||
<input type="hidden" name="type" value="deposit">
|
<input type="hidden" name="type" value="deposit">
|
||||||
<input type="hidden" name="target_euros" value="{{div $r.Form.DreamCostCents 100}}">
|
<input type="hidden" name="target_euros" value="{{div $r.Form.DreamCostCents 100}}">
|
||||||
<input type="hidden" name="deadline" value="{{$r.FinalDate.Format "2006-01"}}">
|
<input type="hidden" name="deadline" value="{{$r.FinalDate.Format "2006-01"}}">
|
||||||
<div style="flex:1; min-width:200px;">
|
<div style="flex:1; min-width:200px;">
|
||||||
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">Goal name</label>
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "goals.planner.transition_result.label_goal_name"}}</label>
|
||||||
<input name="name" required placeholder="e.g. New property, Upgrade car…"
|
<input name="name" required placeholder="{{$d.T.Get "goals.planner.transition_result.placeholder_goal_name"}}"
|
||||||
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary" style="white-space:nowrap;">Save goal →</button>
|
<button type="submit" class="btn btn-primary" style="white-space:nowrap;">{{$d.T.Get "goals.planner.transition_result.btn_save_goal"}}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -21,50 +21,50 @@
|
|||||||
.badge-committed { background:rgba(76,175,80,0.15); color:#4caf50; }
|
.badge-committed { background:rgba(76,175,80,0.15); color:#4caf50; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<h1 style="margin:0 0 24px;">Household</h1>
|
<h1 style="margin:0 0 24px;">{{$d.T.Get "household.title"}}</h1>
|
||||||
|
|
||||||
{{if not $d.HasHousehold}}
|
{{if not $d.HasHousehold}}
|
||||||
<div class="partner-form">
|
<div class="partner-form">
|
||||||
<h2 style="margin:0 0 16px; font-size:1.1rem;">Link a partner account</h2>
|
<h2 style="margin:0 0 16px; font-size:1.1rem;">{{$d.T.Get "household.link_title"}}</h2>
|
||||||
<p style="font-size:0.85rem; color:var(--muted); margin:0 0 20px;">
|
<p style="font-size:0.85rem; color:var(--muted); margin:0 0 20px;">
|
||||||
Combine your finances with a partner to see a shared dashboard. Enter their account email address below.
|
{{$d.T.Get "household.link_desc"}}
|
||||||
</p>
|
</p>
|
||||||
<form method="post" action="/household">
|
<form method="post" action="/household">
|
||||||
<label for="partner_email">Partner email</label>
|
<label for="partner_email">{{$d.T.Get "household.label_partner_email"}}</label>
|
||||||
<input type="email" id="partner_email" name="partner_email" placeholder="partner@example.com" required style="margin-bottom:16px;">
|
<input type="email" id="partner_email" name="partner_email" placeholder="{{$d.T.Get "household.placeholder_email"}}" required style="margin-bottom:16px;">
|
||||||
<button type="submit" class="btn btn-primary" style="width:100%;">Link Partner</button>
|
<button type="submit" class="btn btn-primary" style="width:100%;">{{$d.T.Get "household.btn_link"}}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|
||||||
<div style="display:flex; align-items:center; gap:12px; margin-bottom:20px; background:var(--card); border:1px solid var(--border); border-radius:12px; padding:16px 20px;">
|
<div style="display:flex; align-items:center; gap:12px; margin-bottom:20px; background:var(--card); border:1px solid var(--border); border-radius:12px; padding:16px 20px;">
|
||||||
<div style="flex:1;">
|
<div style="flex:1;">
|
||||||
<div style="font-size:0.8rem; color:var(--muted);">Linked partner</div>
|
<div style="font-size:0.8rem; color:var(--muted);">{{$d.T.Get "household.linked_partner"}}</div>
|
||||||
<div style="font-weight:600;">{{$d.PartnerEmail}}</div>
|
<div style="font-weight:600;">{{$d.PartnerEmail}}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-danger" onclick="unlinkHousehold()" style="font-size:0.8rem; padding:6px 14px;">Unlink</button>
|
<button class="btn btn-danger" onclick="unlinkHousehold()" style="font-size:0.8rem; padding:6px 14px;">{{$d.T.Get "household.btn_unlink"}}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section-title">This Month — Combined View</div>
|
<div class="section-title">{{$d.T.Get "household.this_month_title"}}</div>
|
||||||
<div class="hh-hero">
|
<div class="hh-hero">
|
||||||
<div class="hh-card">
|
<div class="hh-card">
|
||||||
<h3>Combined Income</h3>
|
<h3>{{$d.T.Get "household.combined_income"}}</h3>
|
||||||
<div class="val" style="color:#4caf50;">€{{cents $d.CombinedIncomeCents}}</div>
|
<div class="val" style="color:#4caf50;">€{{cents $d.CombinedIncomeCents}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hh-card">
|
<div class="hh-card">
|
||||||
<h3>My Income</h3>
|
<h3>{{$d.T.Get "household.my_income"}}</h3>
|
||||||
<div class="val">€{{cents $d.MyIncomeCents}}</div>
|
<div class="val">€{{cents $d.MyIncomeCents}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hh-card">
|
<div class="hh-card">
|
||||||
<h3>Partner Income</h3>
|
<h3>{{$d.T.Get "household.partner_income"}}</h3>
|
||||||
<div class="val">€{{cents $d.PartnerIncomeCents}}</div>
|
<div class="val">€{{cents $d.PartnerIncomeCents}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hh-card">
|
<div class="hh-card">
|
||||||
<h3>Combined Expenses</h3>
|
<h3>{{$d.T.Get "household.combined_expenses"}}</h3>
|
||||||
<div class="val" style="color:#f44336;">€{{cents $d.CombinedExpenseCents}}</div>
|
<div class="val" style="color:#f44336;">€{{cents $d.CombinedExpenseCents}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hh-card">
|
<div class="hh-card">
|
||||||
<h3>Disposable</h3>
|
<h3>{{$d.T.Get "household.disposable"}}</h3>
|
||||||
{{if ge $d.CombinedDisposable 0}}
|
{{if ge $d.CombinedDisposable 0}}
|
||||||
<div class="val" style="color:#4caf50;">€{{cents $d.CombinedDisposable}}</div>
|
<div class="val" style="color:#4caf50;">€{{cents $d.CombinedDisposable}}</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
@ -74,28 +74,28 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if or $d.MyGoals $d.PartnerGoals}}
|
{{if or $d.MyGoals $d.PartnerGoals}}
|
||||||
<div class="section-title">Goals</div>
|
<div class="section-title">{{$d.T.Get "household.goals_title"}}</div>
|
||||||
<div class="goals-grid">
|
<div class="goals-grid">
|
||||||
<div style="background:var(--card); border:1px solid var(--border); border-radius:12px; overflow:hidden;">
|
<div style="background:var(--card); border:1px solid var(--border); border-radius:12px; overflow:hidden;">
|
||||||
<div style="padding:12px 16px; font-weight:600; border-bottom:1px solid var(--border); font-size:0.85rem; color:var(--muted);">YOUR GOALS</div>
|
<div style="padding:12px 16px; font-weight:600; border-bottom:1px solid var(--border); font-size:0.85rem; color:var(--muted);">{{$d.T.Get "household.your_goals"}}</div>
|
||||||
{{range $d.MyGoals}}
|
{{range $d.MyGoals}}
|
||||||
<div class="goal-row">
|
<div class="goal-row">
|
||||||
<span>{{.Name}}</span>
|
<span>{{.Name}}</span>
|
||||||
{{if .Committed}}<span class="badge badge-committed">committed</span>{{end}}
|
{{if .Committed}}<span class="badge badge-committed">{{$d.T.Get "household.committed_badge"}}</span>{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div style="padding:16px; color:var(--muted); font-size:0.85rem;">No goals</div>
|
<div style="padding:16px; color:var(--muted); font-size:0.85rem;">{{$d.T.Get "household.no_goals"}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div style="background:var(--card); border:1px solid var(--border); border-radius:12px; overflow:hidden;">
|
<div style="background:var(--card); border:1px solid var(--border); border-radius:12px; overflow:hidden;">
|
||||||
<div style="padding:12px 16px; font-weight:600; border-bottom:1px solid var(--border); font-size:0.85rem; color:var(--muted);">PARTNER GOALS</div>
|
<div style="padding:12px 16px; font-weight:600; border-bottom:1px solid var(--border); font-size:0.85rem; color:var(--muted);">{{$d.T.Get "household.partner_goals"}}</div>
|
||||||
{{range $d.PartnerGoals}}
|
{{range $d.PartnerGoals}}
|
||||||
<div class="goal-row">
|
<div class="goal-row">
|
||||||
<span>{{.Name}}</span>
|
<span>{{.Name}}</span>
|
||||||
{{if .Committed}}<span class="badge badge-committed">committed</span>{{end}}
|
{{if .Committed}}<span class="badge badge-committed">{{$d.T.Get "household.committed_badge"}}</span>{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div style="padding:16px; color:var(--muted); font-size:0.85rem;">No goals</div>
|
<div style="padding:16px; color:var(--muted); font-size:0.85rem;">{{$d.T.Get "household.no_goals"}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -104,8 +104,9 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
const CONFIRM_UNLINK = {{$d.T.Get "household.confirm_unlink" | printf "%q"}};
|
||||||
function unlinkHousehold() {
|
function unlinkHousehold() {
|
||||||
if (!confirm('Unlink household? This only removes the link, not any data.')) return;
|
if (!confirm(CONFIRM_UNLINK)) return;
|
||||||
fetch('/household', { method: 'DELETE' })
|
fetch('/household', { method: 'DELETE' })
|
||||||
.then(r => { if (r.ok) location.reload(); });
|
.then(r => { if (r.ok) location.reload(); });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
{{$d := .}}
|
{{$d := .}}
|
||||||
<h1 style="margin-bottom:24px;">Import</h1>
|
<h1 style="margin-bottom:24px;">{{$d.T.Get "import.title"}}</h1>
|
||||||
|
|
||||||
{{if $d.Error}}
|
{{if $d.Error}}
|
||||||
<div class="card" style="border-color:var(--red); background:var(--red-dim);">
|
<div class="card" style="border-color:var(--red); background:var(--red-dim);">
|
||||||
@ -13,15 +13,15 @@
|
|||||||
<div class="card animate-on-scroll">
|
<div class="card animate-on-scroll">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px; flex-wrap:wrap; gap:8px;">
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px; flex-wrap:wrap; gap:8px;">
|
||||||
<div>
|
<div>
|
||||||
<h2 style="font-size:15px; font-weight:700; color:var(--text); text-transform:none; letter-spacing:0;">Preview</h2>
|
<h2 style="font-size:15px; font-weight:700; color:var(--text); text-transform:none; letter-spacing:0;">{{$d.T.Get "import.preview.section_title"}}</h2>
|
||||||
<p class="text-muted" style="margin-top:3px;">
|
<p class="text-muted" style="margin-top:3px;">
|
||||||
{{$d.Preview.Total}} rows
|
{{$d.Preview.Total}} {{$d.T.Get "import.preview.rows_label"}}
|
||||||
{{if $d.DuplicateCount}}
|
{{if $d.DuplicateCount}}
|
||||||
— <span style="color:var(--yellow, #f59e0b); font-weight:600;">{{$d.DuplicateCount}} already imported</span> (shown greyed out, will be skipped)
|
— <span style="color:var(--yellow, #f59e0b); font-weight:600;">{{$d.DuplicateCount}} {{$d.T.Get "import.preview.already_imported"}}</span> {{$d.T.Get "import.preview.skip_note"}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<a href="/import" class="btn btn-outline btn-sm">← Back</a>
|
<a href="/import" class="btn btn-outline btn-sm">{{$d.T.Get "import.preview.btn_back"}}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST" action="/import/confirm">
|
<form method="POST" action="/import/confirm">
|
||||||
@ -33,10 +33,10 @@
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Date</th>
|
<th>{{$d.T.Get "import.preview.col_date"}}</th>
|
||||||
<th>Description</th>
|
<th>{{$d.T.Get "import.preview.col_description"}}</th>
|
||||||
<th class="text-right">Amount</th>
|
<th class="text-right">{{$d.T.Get "import.preview.col_amount"}}</th>
|
||||||
<th>Category</th>
|
<th>{{$d.T.Get "import.preview.col_category"}}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -45,7 +45,7 @@
|
|||||||
<td style="white-space:nowrap; color:var(--text2);">{{$row.Date}}</td>
|
<td style="white-space:nowrap; color:var(--text2);">{{$row.Date}}</td>
|
||||||
<td style="max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
|
<td style="max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
|
||||||
{{$row.Description}}
|
{{$row.Description}}
|
||||||
{{if $row.Duplicate}}<span style="font-size:11px; font-weight:600; color:var(--muted); margin-left:6px;">duplicate</span>{{end}}
|
{{if $row.Duplicate}}<span style="font-size:11px; font-weight:600; color:var(--muted); margin-left:6px;">{{$d.T.Get "import.preview.duplicate_label"}}</span>{{end}}
|
||||||
</td>
|
</td>
|
||||||
<td class="cents {{if lt $row.AmountCents 0}}negative{{else}}positive{{end}}" style="font-weight:600; white-space:nowrap;">
|
<td class="cents {{if lt $row.AmountCents 0}}negative{{else}}positive{{end}}" style="font-weight:600; white-space:nowrap;">
|
||||||
{{if lt $row.AmountCents 0}}−{{else}}+{{end}}€{{cents (centsAbs $row.AmountCents)}}
|
{{if lt $row.AmountCents 0}}−{{else}}+{{end}}€{{cents (centsAbs $row.AmountCents)}}
|
||||||
@ -71,8 +71,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:flex; gap:10px; margin-top:18px; flex-wrap:wrap;">
|
<div style="display:flex; gap:10px; margin-top:18px; flex-wrap:wrap;">
|
||||||
<button type="submit" class="btn btn-primary">✓ Confirm Import</button>
|
<button type="submit" class="btn btn-primary">{{$d.T.Get "import.preview.btn_confirm"}}</button>
|
||||||
<a href="/import" class="btn btn-outline">Cancel</a>
|
<a href="/import" class="btn btn-outline">{{$d.T.Get "import.preview.btn_cancel"}}</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@ -94,54 +94,54 @@ document.querySelectorAll('.cat-select').forEach(sel => {
|
|||||||
<div class="grid-2" style="align-items:start;">
|
<div class="grid-2" style="align-items:start;">
|
||||||
<div class="card animate-on-scroll">
|
<div class="card animate-on-scroll">
|
||||||
<h2 style="font-size:15px; font-weight:700; color:var(--text); text-transform:none; letter-spacing:0; margin-bottom:18px;">
|
<h2 style="font-size:15px; font-weight:700; color:var(--text); text-transform:none; letter-spacing:0; margin-bottom:18px;">
|
||||||
Bank Transactions
|
{{$d.T.Get "import.upload.bank_section_title"}}
|
||||||
</h2>
|
</h2>
|
||||||
<form method="POST" action="/import/preview" enctype="multipart/form-data">
|
<form method="POST" action="/import/preview" enctype="multipart/form-data">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Account</label>
|
<label>{{$d.T.Get "import.upload.label_account"}}</label>
|
||||||
<select name="account_id" required>
|
<select name="account_id" required>
|
||||||
<option value="">Select account…</option>
|
<option value="">{{$d.T.Get "import.upload.placeholder_account"}}</option>
|
||||||
{{range $d.Accounts}}
|
{{range $d.Accounts}}
|
||||||
<option value="{{.ID}}">{{.Name}} ({{.Type}})</option>
|
<option value="{{.ID}}">{{.Name}} ({{.Type}})</option>
|
||||||
{{end}}
|
{{end}}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Bank / Format</label>
|
<label>{{$d.T.Get "import.upload.label_format"}}</label>
|
||||||
<select name="format">
|
<select name="format">
|
||||||
<option value="cgd">Caixa Geral de Depósitos (CGD)</option>
|
<option value="cgd">{{$d.T.Get "import.upload.format_cgd"}}</option>
|
||||||
<option value="traderepublic">Trade Republic Card</option>
|
<option value="traderepublic">{{$d.T.Get "import.upload.format_tr"}}</option>
|
||||||
<option value="generic">Generic CSV</option>
|
<option value="generic">{{$d.T.Get "import.upload.format_generic"}}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>CSV File</label>
|
<label>{{$d.T.Get "import.upload.label_csv_file"}}</label>
|
||||||
<input type="file" name="file" accept=".csv,.txt" required
|
<input type="file" name="file" accept=".csv,.txt" required
|
||||||
style="padding:10px; cursor:pointer; font-size:13px;">
|
style="padding:10px; cursor:pointer; font-size:13px;">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary" style="width:100%;">Preview Import</button>
|
<button type="submit" class="btn btn-primary" style="width:100%;">{{$d.T.Get "import.upload.btn_preview"}}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card animate-on-scroll">
|
<div class="card animate-on-scroll">
|
||||||
<h2 style="font-size:15px; font-weight:700; color:var(--text); text-transform:none; letter-spacing:0; margin-bottom:18px;">
|
<h2 style="font-size:15px; font-weight:700; color:var(--text); text-transform:none; letter-spacing:0; margin-bottom:18px;">
|
||||||
Securities Trades
|
{{$d.T.Get "import.upload.securities_title"}}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-muted" style="margin-bottom:16px; font-size:13px; line-height:1.6;">
|
<p class="text-muted" style="margin-bottom:16px; font-size:13px; line-height:1.6;">
|
||||||
Upload your Trade Republic securities CSV to import buy/sell trades into your portfolio.
|
{{$d.T.Get "import.upload.securities_desc"}}
|
||||||
</p>
|
</p>
|
||||||
<form method="POST" action="/import/securities" enctype="multipart/form-data">
|
<form method="POST" action="/import/securities" enctype="multipart/form-data">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Trade Republic Securities CSV</label>
|
<label>{{$d.T.Get "import.upload.label_securities_file"}}</label>
|
||||||
<input type="file" name="file" accept=".csv,.txt" required
|
<input type="file" name="file" accept=".csv,.txt" required
|
||||||
style="padding:10px; cursor:pointer; font-size:13px;">
|
style="padding:10px; cursor:pointer; font-size:13px;">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary" style="width:100%;">Import Trades</button>
|
<button type="submit" class="btn btn-primary" style="width:100%;">{{$d.T.Get "import.upload.btn_import_trades"}}</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div style="margin-top:20px; padding-top:16px; border-top:1px solid var(--border);">
|
<div style="margin-top:20px; padding-top:16px; border-top:1px solid var(--border);">
|
||||||
<p class="text-muted" style="font-size:12px; line-height:1.6;">
|
<p class="text-muted" style="font-size:12px; line-height:1.6;">
|
||||||
After importing, visit <a href="/portfolio" style="color:var(--accent);">Portfolio</a> to see live prices and P&L.
|
{{$d.T.Get "import.upload.after_import_note"}} <a href="/portfolio" style="color:var(--accent);">Portfolio</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,29 +2,29 @@
|
|||||||
{{$d := .}}
|
{{$d := .}}
|
||||||
|
|
||||||
<div style="display:flex; align-items:baseline; justify-content:space-between; margin-bottom:24px; flex-wrap:wrap; gap:8px;">
|
<div style="display:flex; align-items:baseline; justify-content:space-between; margin-bottom:24px; flex-wrap:wrap; gap:8px;">
|
||||||
<h1>Net Worth</h1>
|
<h1>{{$d.T.Get "networth.title"}}</h1>
|
||||||
<span class="text-muted">{{if $d.Email}}{{$d.Email}}{{end}}</span>
|
<span class="text-muted">{{if $d.Email}}{{$d.Email}}{{end}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hero -->
|
<!-- Hero -->
|
||||||
<div class="card animate-on-scroll" style="margin-bottom:16px; padding:24px 28px;">
|
<div class="card animate-on-scroll" style="margin-bottom:16px; padding:24px 28px;">
|
||||||
<div style="font-size:12px; color:var(--text2); text-transform:uppercase; letter-spacing:.5px; margin-bottom:10px; display:flex; align-items:center; gap:8px;">
|
<div style="font-size:12px; color:var(--text2); text-transform:uppercase; letter-spacing:.5px; margin-bottom:10px; display:flex; align-items:center; gap:8px;">
|
||||||
Total net worth
|
{{$d.T.Get "networth.hero_label"}}
|
||||||
<span style="font-size:11px; background:var(--bg3); color:var(--text3); padding:2px 8px; border-radius:99px; font-weight:400; text-transform:none; letter-spacing:0;">
|
<span style="font-size:11px; background:var(--bg3); color:var(--text3); padding:2px 8px; border-radius:99px; font-weight:400; text-transform:none; letter-spacing:0;">
|
||||||
cash balance + portfolio
|
{{$d.T.Get "networth.hero_formula"}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="animate-counter {{if lt $d.NetWorthCents 0}}negative{{else}}positive{{end}}"
|
<div class="animate-counter {{if lt $d.NetWorthCents 0}}negative{{else}}positive{{end}}"
|
||||||
style="font-size:42px; font-weight:500; letter-spacing:-1.5px; line-height:1;"
|
style="font-size:42px; font-weight:500; letter-spacing:-1.5px; line-height:1;"
|
||||||
data-target="{{$d.NetWorthCents}}" data-prefix="€">€0.00</div>
|
data-target="{{$d.NetWorthCents}}" data-prefix="€">€0.00</div>
|
||||||
<div style="margin-top:14px; display:flex; flex-wrap:wrap; gap:18px; font-size:13px; color:var(--text3);">
|
<div style="margin-top:14px; display:flex; flex-wrap:wrap; gap:18px; font-size:13px; color:var(--text3);">
|
||||||
<span>💵 Cash <strong style="color:var(--text2);">€{{cents $d.CashCents}}</strong></span>
|
<span>{{$d.T.Get "networth.cash_label"}} <strong style="color:var(--text2);">€{{cents $d.CashCents}}</strong></span>
|
||||||
<span>📈 Portfolio <strong style="color:var(--text2);">€{{cents $d.PortfolioCents}}</strong></span>
|
<span>{{$d.T.Get "networth.portfolio_label"}} <strong style="color:var(--text2);">€{{cents $d.PortfolioCents}}</strong></span>
|
||||||
{{if $d.PropertyValueCents}}
|
{{if $d.PropertyValueCents}}
|
||||||
<span>🏠 Property equity <strong style="color:var(--text2);">€{{cents $d.PropertyEquityCents}}</strong></span>
|
<span>{{$d.T.Get "networth.property_equity_label"}} <strong style="color:var(--text2);">€{{cents $d.PropertyEquityCents}}</strong></span>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if $d.CreditCents}}
|
{{if $d.CreditCents}}
|
||||||
<span>💳 Credit <strong style="color:var(--red);">-€{{cents $d.CreditCents}}</strong></span>
|
<span>{{$d.T.Get "networth.credit_label"}} <strong style="color:var(--red);">-€{{cents $d.CreditCents}}</strong></span>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -33,26 +33,26 @@
|
|||||||
<div class="grid" style="margin-bottom:16px;">
|
<div class="grid" style="margin-bottom:16px;">
|
||||||
|
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Cash balance</h2>
|
<h2>{{$d.T.Get "networth.cards.cash_balance"}}</h2>
|
||||||
<div class="value animate-counter {{if lt $d.CashCents 0}}negative{{else}}positive{{end}}"
|
<div class="value animate-counter {{if lt $d.CashCents 0}}negative{{else}}positive{{end}}"
|
||||||
data-target="{{$d.CashCents}}" data-prefix="€">€0.00</div>
|
data-target="{{$d.CashCents}}" data-prefix="€">€0.00</div>
|
||||||
<p style="font-size:12px; color:var(--text3); margin-top:6px;">all transaction history</p>
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "networth.cards.cash_balance_sub"}}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Portfolio{{if not $d.PortfolioPricesAvailable}} (cost basis){{end}}</h2>
|
<h2>{{$d.T.Get "networth.cards.portfolio"}}{{if not $d.PortfolioPricesAvailable}} ({{$d.T.Get "networth.cards.portfolio_cost_basis"}}){{end}}</h2>
|
||||||
<div class="value animate-counter positive"
|
<div class="value animate-counter positive"
|
||||||
data-target="{{$d.PortfolioCents}}" data-prefix="€">€0.00</div>
|
data-target="{{$d.PortfolioCents}}" data-prefix="€">€0.00</div>
|
||||||
{{if $d.PortfolioPricesAvailable}}
|
{{if $d.PortfolioPricesAvailable}}
|
||||||
<p style="font-size:12px; color:var(--text3); margin-top:6px;">market value</p>
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "networth.cards.portfolio_market"}}</p>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p style="font-size:12px; color:var(--text3); margin-top:6px;">prices unavailable · cost basis shown</p>
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "networth.cards.portfolio_cost_shown"}}</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if $d.PropertyValueCents}}
|
{{if $d.PropertyValueCents}}
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Property equity</h2>
|
<h2>{{$d.T.Get "networth.cards.property_equity"}}</h2>
|
||||||
<div class="value animate-counter {{if lt $d.PropertyEquityCents 0}}negative{{else}}positive{{end}}"
|
<div class="value animate-counter {{if lt $d.PropertyEquityCents 0}}negative{{else}}positive{{end}}"
|
||||||
data-target="{{$d.PropertyEquityCents}}" data-prefix="€">€0.00</div>
|
data-target="{{$d.PropertyEquityCents}}" data-prefix="€">€0.00</div>
|
||||||
<p style="font-size:12px; color:var(--text3); margin-top:6px;">
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">
|
||||||
@ -63,9 +63,9 @@
|
|||||||
|
|
||||||
{{if $d.CreditCents}}
|
{{if $d.CreditCents}}
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Credit / liabilities</h2>
|
<h2>{{$d.T.Get "networth.cards.credit_liabilities"}}</h2>
|
||||||
<div class="value negative animate-counter" data-target="{{$d.CreditCents}}" data-prefix="€">€0.00</div>
|
<div class="value negative animate-counter" data-target="{{$d.CreditCents}}" data-prefix="€">€0.00</div>
|
||||||
<p style="font-size:12px; color:var(--text3); margin-top:6px;">outstanding balance</p>
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "networth.cards.outstanding_balance"}}</p>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
@ -75,8 +75,8 @@
|
|||||||
{{if $d.History}}
|
{{if $d.History}}
|
||||||
<div class="card animate-on-scroll">
|
<div class="card animate-on-scroll">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
||||||
<h2>Net worth over time</h2>
|
<h2>{{$d.T.Get "networth.chart.section_title"}}</h2>
|
||||||
<span style="font-size:11px; color:var(--text3);">cumulative · all time</span>
|
<span style="font-size:11px; color:var(--text3);">{{$d.T.Get "networth.chart.subtitle"}}</span>
|
||||||
</div>
|
</div>
|
||||||
<canvas id="nw-chart" height="220"></canvas>
|
<canvas id="nw-chart" height="220"></canvas>
|
||||||
</div>
|
</div>
|
||||||
@ -99,7 +99,7 @@
|
|||||||
const pts = netData.length > 24 ? 0 : 3;
|
const pts = netData.length > 24 ? 0 : 3;
|
||||||
|
|
||||||
const datasets = [{
|
const datasets = [{
|
||||||
label: 'Net Worth',
|
label: '{{$d.T.Get "networth.chart.legend_net_worth"}}',
|
||||||
data: netData.map(v => v / 100),
|
data: netData.map(v => v / 100),
|
||||||
borderColor: accent,
|
borderColor: accent,
|
||||||
backgroundColor: isDark ? 'rgba(105,121,248,0.07)' : 'rgba(67,85,232,0.05)',
|
backgroundColor: isDark ? 'rgba(105,121,248,0.07)' : 'rgba(67,85,232,0.05)',
|
||||||
@ -113,7 +113,7 @@
|
|||||||
|
|
||||||
if (hasProperty) {
|
if (hasProperty) {
|
||||||
datasets.push({
|
datasets.push({
|
||||||
label: 'Loans outstanding',
|
label: '{{$d.T.Get "networth.chart.legend_loans"}}',
|
||||||
data: liabData.map(v => -(v / 100)),
|
data: liabData.map(v => -(v / 100)),
|
||||||
borderColor: red,
|
borderColor: red,
|
||||||
backgroundColor: isDark ? 'rgba(248,113,113,0.06)' : 'rgba(239,68,68,0.04)',
|
backgroundColor: isDark ? 'rgba(248,113,113,0.06)' : 'rgba(239,68,68,0.04)',
|
||||||
@ -161,9 +161,9 @@
|
|||||||
{{else}}
|
{{else}}
|
||||||
<div class="card empty-state animate-on-scroll">
|
<div class="card empty-state animate-on-scroll">
|
||||||
<div style="font-size:48px; margin-bottom:16px;">📊</div>
|
<div style="font-size:48px; margin-bottom:16px;">📊</div>
|
||||||
<h3>No transaction history yet</h3>
|
<h3>{{$d.T.Get "networth.empty.title"}}</h3>
|
||||||
<p style="margin-bottom:20px;">Import some transactions to see your net worth over time.</p>
|
<p style="margin-bottom:20px;">{{$d.T.Get "networth.empty.desc"}}</p>
|
||||||
<a href="/import" class="btn btn-primary">Import transactions →</a>
|
<a href="/import" class="btn btn-primary">{{$d.T.Get "networth.empty.btn_import"}}</a>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|||||||
@ -28,34 +28,34 @@
|
|||||||
.badge-committed { display:inline-block; padding:2px 7px; border-radius:20px; font-size:0.7rem; font-weight:600; background:rgba(76,175,80,0.15); color:#4caf50; }
|
.badge-committed { display:inline-block; padding:2px 7px; border-radius:20px; font-size:0.7rem; font-weight:600; background:rgba(76,175,80,0.15); color:#4caf50; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<h1 style="margin:0 0 20px;">People</h1>
|
<h1 style="margin:0 0 20px;">{{$d.T.Get "people.title"}}</h1>
|
||||||
|
|
||||||
<div class="tab-bar">
|
<div class="tab-bar">
|
||||||
<a href="/people?tab=sharing" class="{{if eq $d.Tab "sharing"}}active{{end}}">Sharing</a>
|
<a href="/people?tab=sharing" class="{{if eq $d.Tab "sharing"}}active{{end}}">{{$d.T.Get "people.tab_sharing"}}</a>
|
||||||
<a href="/people?tab=household" class="{{if eq $d.Tab "household"}}active{{end}}">Household</a>
|
<a href="/people?tab=household" class="{{if eq $d.Tab "household"}}active{{end}}">{{$d.T.Get "people.tab_household"}}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if eq $d.Tab "sharing"}}
|
{{if eq $d.Tab "sharing"}}
|
||||||
|
|
||||||
<div class="people-card">
|
<div class="people-card">
|
||||||
<h3>Grant read access</h3>
|
<h3>{{$d.T.Get "people.sharing.grant_title"}}</h3>
|
||||||
<p style="font-size:0.83rem; color:var(--muted); margin:0 0 14px;">Enter another user's ID to let them view your finances in read-only mode.</p>
|
<p style="font-size:0.83rem; color:var(--muted); margin:0 0 14px;">{{$d.T.Get "people.sharing.grant_desc"}}</p>
|
||||||
<form method="post" action="/people?tab=sharing">
|
<form method="post" action="/people?tab=sharing">
|
||||||
<input type="hidden" name="_action" value="share">
|
<input type="hidden" name="_action" value="share">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<input type="text" name="viewer_id" placeholder="User ID or email" required>
|
<input type="text" name="viewer_id" placeholder="{{$d.T.Get "people.sharing.placeholder_viewer"}}" required>
|
||||||
<button type="submit" class="btn btn-primary">Add</button>
|
<button type="submit" class="btn btn-primary">{{$d.T.Get "people.sharing.btn_add"}}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if $d.Viewers}}
|
{{if $d.Viewers}}
|
||||||
<div class="people-card">
|
<div class="people-card">
|
||||||
<h3>People with access to your data</h3>
|
<h3>{{$d.T.Get "people.sharing.viewers_title"}}</h3>
|
||||||
{{range $d.Viewers}}
|
{{range $d.Viewers}}
|
||||||
<div class="viewer-row">
|
<div class="viewer-row">
|
||||||
<span>{{.Email}}</span>
|
<span>{{.Email}}</span>
|
||||||
<button class="btn btn-danger" onclick="revokeShare('{{.ID}}')">Revoke</button>
|
<button class="btn btn-danger" onclick="revokeShare('{{.ID}}')">{{$d.T.Get "people.sharing.btn_revoke"}}</button>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
@ -63,31 +63,31 @@
|
|||||||
|
|
||||||
{{if $d.Granted}}
|
{{if $d.Granted}}
|
||||||
<div class="people-card">
|
<div class="people-card">
|
||||||
<h3>Accounts you can view</h3>
|
<h3>{{$d.T.Get "people.sharing.granted_title"}}</h3>
|
||||||
{{range $d.Granted}}
|
{{range $d.Granted}}
|
||||||
<div class="viewer-row">
|
<div class="viewer-row">
|
||||||
<span style="color:var(--muted);">{{.OwnerID}}</span>
|
<span style="color:var(--muted);">{{.OwnerID}}</span>
|
||||||
<a href="/?as={{.OwnerID}}" style="font-size:0.8rem; color:var(--accent);">View →</a>
|
<a href="/?as={{.OwnerID}}" style="font-size:0.8rem; color:var(--accent);">{{$d.T.Get "people.sharing.view_link"}}</a>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if and (not $d.Viewers) (not $d.Granted)}}
|
{{if and (not $d.Viewers) (not $d.Granted)}}
|
||||||
<div class="people-card"><div class="empty-state">No sharing configured yet.</div></div>
|
<div class="people-card"><div class="empty-state">{{$d.T.Get "people.sharing.no_sharing_msg"}}</div></div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{else}}{{/* household tab */}}
|
{{else}}{{/* household tab */}}
|
||||||
|
|
||||||
{{if not $d.HasHousehold}}
|
{{if not $d.HasHousehold}}
|
||||||
<div class="people-card" style="max-width:480px;">
|
<div class="people-card" style="max-width:480px;">
|
||||||
<h3>Link a partner account</h3>
|
<h3>{{$d.T.Get "people.household.link_title"}}</h3>
|
||||||
<p style="font-size:0.83rem; color:var(--muted); margin:0 0 16px;">Combine finances with a partner to see a shared monthly overview and goals.</p>
|
<p style="font-size:0.83rem; color:var(--muted); margin:0 0 16px;">{{$d.T.Get "people.household.link_desc"}}</p>
|
||||||
<form method="post" action="/people?tab=household">
|
<form method="post" action="/people?tab=household">
|
||||||
<input type="hidden" name="_action" value="household">
|
<input type="hidden" name="_action" value="household">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<input type="email" name="partner_email" placeholder="Partner's email" required>
|
<input type="email" name="partner_email" placeholder="{{$d.T.Get "people.household.placeholder_email"}}" required>
|
||||||
<button type="submit" class="btn btn-primary">Link</button>
|
<button type="submit" class="btn btn-primary">{{$d.T.Get "people.household.btn_link"}}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@ -95,31 +95,31 @@
|
|||||||
|
|
||||||
<div class="people-card" style="display:flex; align-items:center; gap:14px;">
|
<div class="people-card" style="display:flex; align-items:center; gap:14px;">
|
||||||
<div style="flex:1;">
|
<div style="flex:1;">
|
||||||
<div style="font-size:0.78rem; color:var(--muted); margin-bottom:2px;">Linked partner</div>
|
<div style="font-size:0.78rem; color:var(--muted); margin-bottom:2px;">{{$d.T.Get "people.household.linked_partner"}}</div>
|
||||||
<div style="font-weight:600;">{{$d.PartnerEmail}}</div>
|
<div style="font-weight:600;">{{$d.PartnerEmail}}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-danger" onclick="unlinkHousehold()">Unlink</button>
|
<button class="btn btn-danger" onclick="unlinkHousehold()">{{$d.T.Get "people.household.btn_unlink"}}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<label>Combined Income</label>
|
<label>{{$d.T.Get "people.household.stat_combined_income"}}</label>
|
||||||
<div class="val" style="color:#4caf50;">€{{cents $d.CombinedIncomeCents}}</div>
|
<div class="val" style="color:#4caf50;">€{{cents $d.CombinedIncomeCents}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<label>My Income</label>
|
<label>{{$d.T.Get "people.household.stat_my_income"}}</label>
|
||||||
<div class="val">€{{cents $d.MyIncomeCents}}</div>
|
<div class="val">€{{cents $d.MyIncomeCents}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<label>Partner Income</label>
|
<label>{{$d.T.Get "people.household.stat_partner_income"}}</label>
|
||||||
<div class="val">€{{cents $d.PartnerIncomeCents}}</div>
|
<div class="val">€{{cents $d.PartnerIncomeCents}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<label>Combined Expenses</label>
|
<label>{{$d.T.Get "people.household.stat_combined_expenses"}}</label>
|
||||||
<div class="val" style="color:#f44336;">€{{cents $d.CombinedExpenseCents}}</div>
|
<div class="val" style="color:#f44336;">€{{cents $d.CombinedExpenseCents}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<label>Disposable</label>
|
<label>{{$d.T.Get "people.household.stat_disposable"}}</label>
|
||||||
<div class="val" style="{{if ge $d.CombinedDisposable 0}}color:#4caf50{{else}}color:#f44336{{end}};">
|
<div class="val" style="{{if ge $d.CombinedDisposable 0}}color:#4caf50{{else}}color:#f44336{{end}};">
|
||||||
{{if lt $d.CombinedDisposable 0}}-{{end}}€{{cents (centsAbs $d.CombinedDisposable)}}
|
{{if lt $d.CombinedDisposable 0}}-{{end}}€{{cents (centsAbs $d.CombinedDisposable)}}
|
||||||
</div>
|
</div>
|
||||||
@ -129,22 +129,22 @@
|
|||||||
{{if or $d.MyGoals $d.PartnerGoals}}
|
{{if or $d.MyGoals $d.PartnerGoals}}
|
||||||
<div class="goals-grid">
|
<div class="goals-grid">
|
||||||
<div class="people-card" style="margin-bottom:0;">
|
<div class="people-card" style="margin-bottom:0;">
|
||||||
<h3>Your Goals</h3>
|
<h3>{{$d.T.Get "people.household.your_goals"}}</h3>
|
||||||
{{range $d.MyGoals}}
|
{{range $d.MyGoals}}
|
||||||
<div class="goal-item">
|
<div class="goal-item">
|
||||||
<span>{{.Name}}</span>
|
<span>{{.Name}}</span>
|
||||||
{{if .Committed}}<span class="badge-committed">committed</span>{{end}}
|
{{if .Committed}}<span class="badge-committed">{{$d.T.Get "people.household.committed_badge"}}</span>{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{else}}<div style="color:var(--muted); font-size:0.83rem;">No goals</div>{{end}}
|
{{else}}<div style="color:var(--muted); font-size:0.83rem;">{{$d.T.Get "people.household.no_goals"}}</div>{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="people-card" style="margin-bottom:0;">
|
<div class="people-card" style="margin-bottom:0;">
|
||||||
<h3>Partner Goals</h3>
|
<h3>{{$d.T.Get "people.household.partner_goals"}}</h3>
|
||||||
{{range $d.PartnerGoals}}
|
{{range $d.PartnerGoals}}
|
||||||
<div class="goal-item">
|
<div class="goal-item">
|
||||||
<span>{{.Name}}</span>
|
<span>{{.Name}}</span>
|
||||||
{{if .Committed}}<span class="badge-committed">committed</span>{{end}}
|
{{if .Committed}}<span class="badge-committed">{{$d.T.Get "people.household.committed_badge"}}</span>{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{else}}<div style="color:var(--muted); font-size:0.83rem;">No goals</div>{{end}}
|
{{else}}<div style="color:var(--muted); font-size:0.83rem;">{{$d.T.Get "people.household.no_goals"}}</div>{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
@ -153,13 +153,16 @@
|
|||||||
{{end}}{{/* tab */}}
|
{{end}}{{/* tab */}}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
const CONFIRM_REVOKE = {{$d.T.Get "people.sharing.confirm_revoke" | printf "%q"}};
|
||||||
|
const CONFIRM_UNLINK = {{$d.T.Get "people.household.confirm_unlink" | printf "%q"}};
|
||||||
|
|
||||||
function revokeShare(id) {
|
function revokeShare(id) {
|
||||||
if (!confirm('Revoke access for this user?')) return;
|
if (!confirm(CONFIRM_REVOKE)) return;
|
||||||
fetch('/people/' + id + '?kind=share', { method: 'DELETE' })
|
fetch('/people/' + id + '?kind=share', { method: 'DELETE' })
|
||||||
.then(r => { if (r.ok) location.reload(); });
|
.then(r => { if (r.ok) location.reload(); });
|
||||||
}
|
}
|
||||||
function unlinkHousehold() {
|
function unlinkHousehold() {
|
||||||
if (!confirm('Unlink household? This only removes the link, not any data.')) return;
|
if (!confirm(CONFIRM_UNLINK)) return;
|
||||||
fetch('/people/_?kind=household', { method: 'DELETE' })
|
fetch('/people/_?kind=household', { method: 'DELETE' })
|
||||||
.then(r => { if (r.ok) location.href = '/people?tab=household'; });
|
.then(r => { if (r.ok) location.href = '/people?tab=household'; });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,106 +3,97 @@
|
|||||||
{{$r := .Result}}
|
{{$r := .Result}}
|
||||||
|
|
||||||
<div style="display:flex; align-items:baseline; justify-content:space-between; margin-bottom:24px; flex-wrap:wrap; gap:8px;">
|
<div style="display:flex; align-items:baseline; justify-content:space-between; margin-bottom:24px; flex-wrap:wrap; gap:8px;">
|
||||||
<h1>Goal Planner</h1>
|
<h1>{{$d.T.Get "plan.title"}}</h1>
|
||||||
<span class="text-muted">{{if $d.Email}}{{$d.Email}}{{end}}</span>
|
<span class="text-muted">{{if $d.Email}}{{$d.Email}}{{end}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Inputs form -->
|
<!-- Inputs form -->
|
||||||
<div class="card animate-on-scroll" style="margin-bottom:20px;">
|
<div class="card animate-on-scroll" style="margin-bottom:20px;">
|
||||||
<h2 style="margin-bottom:4px;">Your scenario</h2>
|
<h2 style="margin-bottom:4px;">{{$d.T.Get "plan.scenario_title"}}</h2>
|
||||||
<p style="font-size:13px; color:var(--text3); margin-bottom:16px;">
|
<p style="font-size:13px; color:var(--text3); margin-bottom:16px;">
|
||||||
Model any transition where you hold an asset with a loan, want to acquire a new one, then sell the old to fund the new.
|
{{$d.T.Get "plan.scenario_desc"}}
|
||||||
</p>
|
</p>
|
||||||
<form method="GET" action="/plan">
|
<form method="GET" action="/plan">
|
||||||
<input type="hidden" name="run" value="1">
|
<input type="hidden" name="run" value="1">
|
||||||
|
|
||||||
<div class="grid" style="gap:14px; margin-bottom:16px;">
|
<div class="grid" style="gap:14px; margin-bottom:16px;">
|
||||||
|
|
||||||
<!-- Current asset -->
|
|
||||||
<div>
|
<div>
|
||||||
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">Current asset (optional)</label>
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "plan.label_current_asset"}}</label>
|
||||||
<select name="property_id" style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px;">
|
<select name="property_id" style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px;">
|
||||||
<option value="">— none selected —</option>
|
<option value="">{{$d.T.Get "plan.option_none_asset"}}</option>
|
||||||
{{range $d.Properties}}
|
{{range $d.Properties}}
|
||||||
<option value="{{.ID}}" {{if and $d.HasResult (eq $d.Form.PropertyID .ID)}}selected{{end}}>{{.Name}} (€{{cents .CurrentValueCents}})</option>
|
<option value="{{.ID}}" {{if and $d.HasResult (eq $d.Form.PropertyID .ID)}}selected{{end}}>{{.Name}} (€{{cents .CurrentValueCents}})</option>
|
||||||
{{end}}
|
{{end}}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Current loan -->
|
|
||||||
<div>
|
<div>
|
||||||
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">Current loan (optional)</label>
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "plan.label_current_loan"}}</label>
|
||||||
<select name="loan_id" style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px;">
|
<select name="loan_id" style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px;">
|
||||||
<option value="">— none selected —</option>
|
<option value="">{{$d.T.Get "plan.option_none_loan"}}</option>
|
||||||
{{range $d.Loans}}
|
{{range $d.Loans}}
|
||||||
<option value="{{.ID}}" {{if and $d.HasResult (eq $d.Form.LoanID .ID)}}selected{{end}}>{{.Name}} (€{{cents .BalanceCents}} left)</option>
|
<option value="{{.ID}}" {{if and $d.HasResult (eq $d.Form.LoanID .ID)}}selected{{end}}>{{.Name}} (€{{cents .BalanceCents}} left)</option>
|
||||||
{{end}}
|
{{end}}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Goal cost -->
|
|
||||||
<div>
|
<div>
|
||||||
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">New goal cost (€)</label>
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "plan.label_dream_cost"}}</label>
|
||||||
<input type="number" name="dream_cost" min="0" step="1000"
|
<input type="number" name="dream_cost" min="0" step="1000"
|
||||||
value="{{if $d.HasResult}}{{div $d.Form.DreamCostCents 100}}{{end}}"
|
value="{{if $d.HasResult}}{{div $d.Form.DreamCostCents 100}}{{end}}"
|
||||||
placeholder="e.g. 350000"
|
placeholder="{{$d.T.Get "plan.placeholder_dream_cost"}}"
|
||||||
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Down payment % -->
|
|
||||||
<div>
|
<div>
|
||||||
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">Down payment (%)</label>
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "plan.label_down_pct"}}</label>
|
||||||
<input type="number" name="down_pct" min="0" max="100" step="1"
|
<input type="number" name="down_pct" min="0" max="100" step="1"
|
||||||
value="{{if $d.HasResult}}{{round $d.Form.DownPaymentPct}}{{else}}20{{end}}"
|
value="{{if $d.HasResult}}{{round $d.Form.DownPaymentPct}}{{else}}20{{end}}"
|
||||||
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- New loan rate -->
|
|
||||||
<div>
|
<div>
|
||||||
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">New loan rate (% annual)</label>
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "plan.label_loan_rate"}}</label>
|
||||||
<input type="number" name="const_rate" min="0" max="30" step="0.1"
|
<input type="number" name="const_rate" min="0" max="30" step="0.1"
|
||||||
value="{{if $d.HasResult}}{{$d.Form.ConstructionRatePct}}{{else}}4.0{{end}}"
|
value="{{if $d.HasResult}}{{$d.Form.ConstructionRatePct}}{{else}}4.0{{end}}"
|
||||||
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- New loan term -->
|
|
||||||
<div>
|
<div>
|
||||||
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">New loan term (years)</label>
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "plan.label_loan_term"}}</label>
|
||||||
<input type="number" name="const_term" min="1" max="40" step="1"
|
<input type="number" name="const_term" min="1" max="40" step="1"
|
||||||
value="{{if $d.HasResult}}{{$d.Form.ConstructionTermYears}}{{else}}30{{end}}"
|
value="{{if $d.HasResult}}{{$d.Form.ConstructionTermYears}}{{else}}30{{end}}"
|
||||||
placeholder="30"
|
placeholder="{{$d.T.Get "plan.placeholder_loan_term"}}"
|
||||||
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Acquisition period -->
|
|
||||||
<div>
|
<div>
|
||||||
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">Acquisition / build period (months)</label>
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "plan.label_build_months"}}</label>
|
||||||
<input type="number" name="build_months" min="1" max="60" step="1"
|
<input type="number" name="build_months" min="1" max="60" step="1"
|
||||||
value="{{if $d.HasResult}}{{$d.Form.BuildMonths}}{{else}}18{{end}}"
|
value="{{if $d.HasResult}}{{$d.Form.BuildMonths}}{{else}}18{{end}}"
|
||||||
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Monthly savings -->
|
|
||||||
<div>
|
<div>
|
||||||
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">Monthly savings available (€)</label>
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "plan.label_monthly_savings"}}</label>
|
||||||
<input type="number" name="monthly_savings" min="0" step="100"
|
<input type="number" name="monthly_savings" min="0" step="100"
|
||||||
value="{{if $d.HasResult}}{{div $d.Form.MonthlySavingsCents 100}}{{end}}"
|
value="{{if $d.HasResult}}{{div $d.Form.MonthlySavingsCents 100}}{{end}}"
|
||||||
placeholder="e.g. 800"
|
placeholder="{{$d.T.Get "plan.placeholder_savings"}}"
|
||||||
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Expected sale price -->
|
|
||||||
<div>
|
<div>
|
||||||
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">Expected sale price of current asset (€)</label>
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "plan.label_sale_price"}}</label>
|
||||||
<input type="number" name="sale_price" min="0" step="1000"
|
<input type="number" name="sale_price" min="0" step="1000"
|
||||||
value="{{if $d.HasResult}}{{div $d.Form.ExpectedSalePriceCents 100}}{{end}}"
|
value="{{if $d.HasResult}}{{div $d.Form.ExpectedSalePriceCents 100}}{{end}}"
|
||||||
placeholder="leave blank to use current value"
|
placeholder="{{$d.T.Get "plan.placeholder_sale_price"}}"
|
||||||
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary" style="width:100%;">Run simulation →</button>
|
<button type="submit" class="btn btn-primary" style="width:100%;">{{$d.T.Get "plan.btn_run"}}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -117,30 +108,30 @@
|
|||||||
<!-- Summary bar -->
|
<!-- Summary bar -->
|
||||||
<div class="grid" style="margin-bottom:20px;">
|
<div class="grid" style="margin-bottom:20px;">
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Total timeline</h2>
|
<h2>{{$d.T.Get "plan.result_total_timeline"}}</h2>
|
||||||
<div class="value positive">{{$r.TotalYears}}y {{$r.TotalRemMonths}}m</div>
|
<div class="value positive">{{$r.TotalYears}}y {{$r.TotalRemMonths}}m</div>
|
||||||
<p style="font-size:12px; color:var(--text3); margin-top:6px;">until goal is fully paid off</p>
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "plan.result_until_paid"}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Final monthly cost</h2>
|
<h2>{{$d.T.Get "plan.result_final_monthly"}}</h2>
|
||||||
<div class="value positive">€{{cents $r.Phase4MonthlyCents}}</div>
|
<div class="value positive">€{{cents $r.Phase4MonthlyCents}}</div>
|
||||||
<p style="font-size:12px; color:var(--text3); margin-top:6px;">after selling current asset</p>
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "plan.result_after_selling"}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Total interest</h2>
|
<h2>{{$d.T.Get "plan.result_total_interest"}}</h2>
|
||||||
<div class="value" style="color:var(--red);">€{{cents $r.TotalInterestCents}}</div>
|
<div class="value" style="color:var(--red);">€{{cents $r.TotalInterestCents}}</div>
|
||||||
<p style="font-size:12px; color:var(--text3); margin-top:6px;">across both loans combined</p>
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "plan.result_across_both"}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Free by</h2>
|
<h2>{{$d.T.Get "plan.result_free_by"}}</h2>
|
||||||
<div class="value positive" style="font-size:24px;">{{dateShort $r.FinalDate}}</div>
|
<div class="value positive" style="font-size:24px;">{{dateShort $r.FinalDate}}</div>
|
||||||
<p style="font-size:12px; color:var(--text3); margin-top:6px;">fully paid off</p>
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "plan.result_fully_paid"}}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Phase timeline -->
|
<!-- Phase timeline -->
|
||||||
<div class="card animate-on-scroll" style="margin-bottom:20px;">
|
<div class="card animate-on-scroll" style="margin-bottom:20px;">
|
||||||
<h2 style="margin-bottom:20px;">Your roadmap</h2>
|
<h2 style="margin-bottom:20px;">{{$d.T.Get "plan.roadmap_title"}}</h2>
|
||||||
|
|
||||||
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:0; position:relative;">
|
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:0; position:relative;">
|
||||||
|
|
||||||
@ -150,22 +141,22 @@
|
|||||||
<!-- Phase 1 -->
|
<!-- Phase 1 -->
|
||||||
<div style="text-align:center; position:relative; padding:0 8px;">
|
<div style="text-align:center; position:relative; padding:0 8px;">
|
||||||
<div style="width:40px; height:40px; border-radius:50%; background:var(--accent); color:#fff; display:flex; align-items:center; justify-content:center; font-size:18px; margin:0 auto 12px; position:relative; z-index:1;">1</div>
|
<div style="width:40px; height:40px; border-radius:50%; background:var(--accent); color:#fff; display:flex; align-items:center; justify-content:center; font-size:18px; margin:0 auto 12px; position:relative; z-index:1;">1</div>
|
||||||
<div style="font-weight:600; font-size:13px; color:var(--text1); margin-bottom:6px;">Save down payment</div>
|
<div style="font-weight:600; font-size:13px; color:var(--text1); margin-bottom:6px;">{{$d.T.Get "plan.phase1_title"}}</div>
|
||||||
{{if gt $r.Phase1Months 0}}
|
{{if gt $r.Phase1Months 0}}
|
||||||
<div style="font-size:22px; font-weight:500; color:var(--accent); margin-bottom:4px;">{{$r.Phase1Months}}mo</div>
|
<div style="font-size:22px; font-weight:500; color:var(--accent); margin-bottom:4px;">{{$r.Phase1Months}}mo</div>
|
||||||
<div style="font-size:11px; color:var(--text3);">until {{dateShort $r.Phase1EndDate}}</div>
|
<div style="font-size:11px; color:var(--text3);">until {{dateShort $r.Phase1EndDate}}</div>
|
||||||
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
|
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
|
||||||
<div>Target: <strong>€{{cents $r.DownPaymentCents}}</strong></div>
|
<div>{{$d.T.Get "plan.phase1_target"}} <strong>€{{cents $r.DownPaymentCents}}</strong></div>
|
||||||
<div>Already have: <strong>€{{cents $r.AlreadyHaveCents}}</strong></div>
|
<div>{{$d.T.Get "plan.phase1_already_have"}} <strong>€{{cents $r.AlreadyHaveCents}}</strong></div>
|
||||||
<div>Still need: <strong>€{{cents $r.StillNeededCents}}</strong></div>
|
<div>{{$d.T.Get "plan.phase1_still_need"}} <strong>€{{cents $r.StillNeededCents}}</strong></div>
|
||||||
<div>Saving: <strong>€{{cents $r.Form.MonthlySavingsCents}}/mo</strong></div>
|
<div>{{$d.T.Get "plan.phase1_saving"}} <strong>€{{cents $r.Form.MonthlySavingsCents}}/mo</strong></div>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div style="font-size:16px; font-weight:600; color:var(--green); margin-bottom:4px;">Ready now!</div>
|
<div style="font-size:16px; font-weight:600; color:var(--green); margin-bottom:4px;">{{$d.T.Get "plan.phase1_ready"}}</div>
|
||||||
<div style="font-size:11px; color:var(--text3);">equity covers down payment</div>
|
<div style="font-size:11px; color:var(--text3);">{{$d.T.Get "plan.phase1_equity_covers"}}</div>
|
||||||
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
|
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
|
||||||
<div>Down payment: <strong>€{{cents $r.DownPaymentCents}}</strong></div>
|
<div>{{$d.T.Get "plan.phase1_down_payment"}} <strong>€{{cents $r.DownPaymentCents}}</strong></div>
|
||||||
<div>Your equity: <strong style="color:var(--green);">€{{cents $r.AlreadyHaveCents}}</strong></div>
|
<div>{{$d.T.Get "plan.phase1_your_equity"}} <strong style="color:var(--green);">€{{cents $r.AlreadyHaveCents}}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
@ -173,51 +164,51 @@
|
|||||||
<!-- Phase 2 -->
|
<!-- Phase 2 -->
|
||||||
<div style="text-align:center; position:relative; padding:0 8px;">
|
<div style="text-align:center; position:relative; padding:0 8px;">
|
||||||
<div style="width:40px; height:40px; border-radius:50%; background:var(--orange, #f97316); color:#fff; display:flex; align-items:center; justify-content:center; font-size:18px; margin:0 auto 12px; position:relative; z-index:1;">2</div>
|
<div style="width:40px; height:40px; border-radius:50%; background:var(--orange, #f97316); color:#fff; display:flex; align-items:center; justify-content:center; font-size:18px; margin:0 auto 12px; position:relative; z-index:1;">2</div>
|
||||||
<div style="font-weight:600; font-size:13px; color:var(--text1); margin-bottom:6px;">Acquire / build</div>
|
<div style="font-weight:600; font-size:13px; color:var(--text1); margin-bottom:6px;">{{$d.T.Get "plan.phase2_title"}}</div>
|
||||||
<div style="font-size:22px; font-weight:500; color:var(--orange, #f97316); margin-bottom:4px;">{{$r.Phase2Months}}mo</div>
|
<div style="font-size:22px; font-weight:500; color:var(--orange, #f97316); margin-bottom:4px;">{{$r.Phase2Months}}mo</div>
|
||||||
<div style="font-size:11px; color:var(--text3);">until {{dateShort $r.Phase2EndDate}}</div>
|
<div style="font-size:11px; color:var(--text3);">until {{dateShort $r.Phase2EndDate}}</div>
|
||||||
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
|
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
|
||||||
<div>New loan: <strong>€{{cents $r.ConstructionLoanCents}}</strong></div>
|
<div>{{$d.T.Get "plan.phase2_new_loan"}} <strong>€{{cents $r.ConstructionLoanCents}}</strong></div>
|
||||||
{{if $r.CurrentLoan}}
|
{{if $r.CurrentLoan}}
|
||||||
<div>Existing loan: <strong>€{{cents $r.CurrentMonthlyCents}}/mo</strong></div>
|
<div>{{$d.T.Get "plan.phase2_existing_loan"}} <strong>€{{cents $r.CurrentMonthlyCents}}/mo</strong></div>
|
||||||
{{end}}
|
{{end}}
|
||||||
<div>New EMI: <strong>€{{cents $r.ConstructionMonthly}}/mo</strong></div>
|
<div>{{$d.T.Get "plan.phase2_new_emi"}} <strong>€{{cents $r.ConstructionMonthly}}/mo</strong></div>
|
||||||
<div style="border-top:1px solid var(--border); margin-top:6px; padding-top:6px;">Total burden: <strong style="color:var(--orange, #f97316);">€{{cents $r.Phase2MonthlyCents}}/mo</strong></div>
|
<div style="border-top:1px solid var(--border); margin-top:6px; padding-top:6px;">{{$d.T.Get "plan.phase2_total_burden"}} <strong style="color:var(--orange, #f97316);">€{{cents $r.Phase2MonthlyCents}}/mo</strong></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Phase 3 -->
|
<!-- Phase 3 -->
|
||||||
<div style="text-align:center; position:relative; padding:0 8px;">
|
<div style="text-align:center; position:relative; padding:0 8px;">
|
||||||
<div style="width:40px; height:40px; border-radius:50%; background:var(--teal, #14b8a6); color:#fff; display:flex; align-items:center; justify-content:center; font-size:18px; margin:0 auto 12px; position:relative; z-index:1;">3</div>
|
<div style="width:40px; height:40px; border-radius:50%; background:var(--teal, #14b8a6); color:#fff; display:flex; align-items:center; justify-content:center; font-size:18px; margin:0 auto 12px; position:relative; z-index:1;">3</div>
|
||||||
<div style="font-weight:600; font-size:13px; color:var(--text1); margin-bottom:6px;">Sell & transition</div>
|
<div style="font-weight:600; font-size:13px; color:var(--text1); margin-bottom:6px;">{{$d.T.Get "plan.phase3_title"}}</div>
|
||||||
<div style="font-size:16px; font-weight:600; color:var(--teal, #14b8a6); margin-bottom:4px;">One-time event</div>
|
<div style="font-size:16px; font-weight:600; color:var(--teal, #14b8a6); margin-bottom:4px;">{{$d.T.Get "plan.phase3_one_time"}}</div>
|
||||||
<div style="font-size:11px; color:var(--text3);">after acquisition completes</div>
|
<div style="font-size:11px; color:var(--text3);">{{$d.T.Get "plan.phase3_after_acquisition"}}</div>
|
||||||
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
|
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
|
||||||
<div>Sale price: <strong>€{{cents $r.SalePriceCents}}</strong></div>
|
<div>{{$d.T.Get "plan.phase3_sale_price"}} <strong>€{{cents $r.SalePriceCents}}</strong></div>
|
||||||
<div>Pay off loan: <strong style="color:var(--red);">-€{{cents $r.MortgagePayoffCents}}</strong></div>
|
<div>{{$d.T.Get "plan.phase3_pay_off"}} <strong style="color:var(--red);">-€{{cents $r.MortgagePayoffCents}}</strong></div>
|
||||||
<div style="border-top:1px solid var(--border); margin-top:6px; padding-top:6px;">Net proceeds: <strong style="color:var(--green);">€{{cents $r.NetProceedsCents}}</strong></div>
|
<div style="border-top:1px solid var(--border); margin-top:6px; padding-top:6px;">{{$d.T.Get "plan.phase3_net_proceeds"}} <strong style="color:var(--green);">€{{cents $r.NetProceedsCents}}</strong></div>
|
||||||
<div>Applied to new loan</div>
|
<div>{{$d.T.Get "plan.phase3_applied"}}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Phase 4 -->
|
<!-- Phase 4 -->
|
||||||
<div style="text-align:center; position:relative; padding:0 8px;">
|
<div style="text-align:center; position:relative; padding:0 8px;">
|
||||||
<div style="width:40px; height:40px; border-radius:50%; background:var(--green); color:#fff; display:flex; align-items:center; justify-content:center; font-size:18px; margin:0 auto 12px; position:relative; z-index:1;">4</div>
|
<div style="width:40px; height:40px; border-radius:50%; background:var(--green); color:#fff; display:flex; align-items:center; justify-content:center; font-size:18px; margin:0 auto 12px; position:relative; z-index:1;">4</div>
|
||||||
<div style="font-weight:600; font-size:13px; color:var(--text1); margin-bottom:6px;">Goal achieved</div>
|
<div style="font-weight:600; font-size:13px; color:var(--text1); margin-bottom:6px;">{{$d.T.Get "plan.phase4_title"}}</div>
|
||||||
{{if gt $r.Phase4Months 0}}
|
{{if gt $r.Phase4Months 0}}
|
||||||
<div style="font-size:22px; font-weight:500; color:var(--green); margin-bottom:4px;">{{$r.Phase4Months}}mo</div>
|
<div style="font-size:22px; font-weight:500; color:var(--green); margin-bottom:4px;">{{$r.Phase4Months}}mo</div>
|
||||||
<div style="font-size:11px; color:var(--text3);">paid off {{dateShort $r.Phase4EndDate}}</div>
|
<div style="font-size:11px; color:var(--text3);">paid off {{dateShort $r.Phase4EndDate}}</div>
|
||||||
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
|
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
|
||||||
<div>Remaining loan: <strong>€{{cents $r.RemainingBalanceCents}}</strong></div>
|
<div>{{$d.T.Get "plan.phase4_remaining_loan"}} <strong>€{{cents $r.RemainingBalanceCents}}</strong></div>
|
||||||
<div>Monthly payment: <strong style="color:var(--green);">€{{cents $r.Phase4MonthlyCents}}/mo</strong></div>
|
<div>{{$d.T.Get "plan.phase4_monthly_payment"}} <strong style="color:var(--green);">€{{cents $r.Phase4MonthlyCents}}/mo</strong></div>
|
||||||
<div style="font-size:11px; color:var(--text3); margin-top:4px;">just the new loan</div>
|
<div style="font-size:11px; color:var(--text3); margin-top:4px;">{{$d.T.Get "plan.phase4_just_new_loan"}}</div>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div style="font-size:16px; font-weight:600; color:var(--green); margin-bottom:4px;">Fully paid!</div>
|
<div style="font-size:16px; font-weight:600; color:var(--green); margin-bottom:4px;">{{$d.T.Get "plan.phase4_fully_paid"}}</div>
|
||||||
<div style="font-size:11px; color:var(--text3);">sale proceeds cleared the loan</div>
|
<div style="font-size:11px; color:var(--text3);">{{$d.T.Get "plan.phase4_sale_cleared"}}</div>
|
||||||
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
|
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
|
||||||
<div style="color:var(--green); font-weight:600;">No remaining loan!</div>
|
<div style="color:var(--green); font-weight:600;">{{$d.T.Get "plan.phase4_no_remaining"}}</div>
|
||||||
<div>The sale covers everything.</div>
|
<div>{{$d.T.Get "plan.phase4_sale_covers"}}</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
@ -228,8 +219,8 @@
|
|||||||
<!-- Monthly cost chart -->
|
<!-- Monthly cost chart -->
|
||||||
<div class="card animate-on-scroll" style="margin-bottom:20px;">
|
<div class="card animate-on-scroll" style="margin-bottom:20px;">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
||||||
<h2>Monthly cost over time</h2>
|
<h2>{{$d.T.Get "plan.chart_title"}}</h2>
|
||||||
<span style="font-size:11px; color:var(--text3);">what you pay each month</span>
|
<span style="font-size:11px; color:var(--text3);">{{$d.T.Get "plan.chart_subtitle"}}</span>
|
||||||
</div>
|
</div>
|
||||||
<canvas id="cost-chart" height="160"></canvas>
|
<canvas id="cost-chart" height="160"></canvas>
|
||||||
</div>
|
</div>
|
||||||
@ -315,23 +306,23 @@
|
|||||||
|
|
||||||
<!-- Key levers -->
|
<!-- Key levers -->
|
||||||
<div class="card animate-on-scroll">
|
<div class="card animate-on-scroll">
|
||||||
<h2 style="margin-bottom:14px;">Key levers</h2>
|
<h2 style="margin-bottom:14px;">{{$d.T.Get "plan.levers_title"}}</h2>
|
||||||
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; font-size:13px; color:var(--text2);">
|
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; font-size:13px; color:var(--text2);">
|
||||||
<div style="background:var(--bg3); border-radius:var(--radius-sm); padding:12px 14px;">
|
<div style="background:var(--bg3); border-radius:var(--radius-sm); padding:12px 14px;">
|
||||||
<div style="font-weight:600; color:var(--text1); margin-bottom:6px;">Save more monthly</div>
|
<div style="font-weight:600; color:var(--text1); margin-bottom:6px;">{{$d.T.Get "plan.lever1_title"}}</div>
|
||||||
<div>Each extra €100/mo shortens Phase 1 and gets you acquiring sooner.</div>
|
<div>{{$d.T.Get "plan.lever1_desc"}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="background:var(--bg3); border-radius:var(--radius-sm); padding:12px 14px;">
|
<div style="background:var(--bg3); border-radius:var(--radius-sm); padding:12px 14px;">
|
||||||
<div style="font-weight:600; color:var(--text1); margin-bottom:6px;">Increase the down payment</div>
|
<div style="font-weight:600; color:var(--text1); margin-bottom:6px;">{{$d.T.Get "plan.lever2_title"}}</div>
|
||||||
<div>A higher down % reduces the new loan and lowers the double-burden in Phase 2.</div>
|
<div>{{$d.T.Get "plan.lever2_desc"}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="background:var(--bg3); border-radius:var(--radius-sm); padding:12px 14px;">
|
<div style="background:var(--bg3); border-radius:var(--radius-sm); padding:12px 14px;">
|
||||||
<div style="font-weight:600; color:var(--text1); margin-bottom:6px;">Sell at a higher price</div>
|
<div style="font-weight:600; color:var(--text1); margin-bottom:6px;">{{$d.T.Get "plan.lever3_title"}}</div>
|
||||||
<div>Every extra euro from the sale goes straight to reducing the new loan balance.</div>
|
<div>{{$d.T.Get "plan.lever3_desc"}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="background:var(--bg3); border-radius:var(--radius-sm); padding:12px 14px;">
|
<div style="background:var(--bg3); border-radius:var(--radius-sm); padding:12px 14px;">
|
||||||
<div style="font-weight:600; color:var(--text1); margin-bottom:6px;">Negotiate the rate</div>
|
<div style="font-weight:600; color:var(--text1); margin-bottom:6px;">{{$d.T.Get "plan.lever4_title"}}</div>
|
||||||
<div>Even 0.5% less on the new loan saves thousands over the full term.</div>
|
<div>{{$d.T.Get "plan.lever4_desc"}}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -341,15 +332,13 @@
|
|||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
<div class="card empty-state animate-on-scroll">
|
<div class="card empty-state animate-on-scroll">
|
||||||
<div style="font-size:48px; margin-bottom:16px;">🎯</div>
|
<div style="font-size:48px; margin-bottom:16px;">🎯</div>
|
||||||
<h3>Plan your next big goal</h3>
|
<h3>{{$d.T.Get "plan.empty_title"}}</h3>
|
||||||
<p style="margin-bottom:8px; max-width:480px; margin-left:auto; margin-right:auto;">
|
<p style="margin-bottom:8px; max-width:480px; margin-left:auto; margin-right:auto;">
|
||||||
Model any transition where you hold an asset with a loan, want to acquire something new,
|
{{$d.T.Get "plan.empty_desc"}}
|
||||||
and plan to sell the old to fund it — including the double-payment period,
|
|
||||||
the sale, and the final payoff date.
|
|
||||||
</p>
|
</p>
|
||||||
{{if not $d.Properties}}
|
{{if not $d.Properties}}
|
||||||
<p style="font-size:13px; color:var(--text3); margin-top:12px;">
|
<p style="font-size:13px; color:var(--text3); margin-top:12px;">
|
||||||
Tip: <a href="/property" style="color:var(--accent);">add your current asset and loan</a> first so the planner can pre-fill the numbers.
|
Tip: <a href="/property" style="color:var(--accent);">{{$d.T.Get "plan.scenario_desc"}}</a>
|
||||||
</p>
|
</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,21 +1,21 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
{{$d := .}}
|
{{$d := .}}
|
||||||
<h1 style="margin-bottom:24px;">Portfolio</h1>
|
<h1 style="margin-bottom:24px;">{{$d.T.Get "portfolio.title"}}</h1>
|
||||||
|
|
||||||
{{if $d.Holdings}}
|
{{if $d.Holdings}}
|
||||||
|
|
||||||
{{if $d.MissingPrices}}
|
{{if $d.MissingPrices}}
|
||||||
<div style="background:rgba(245,158,11,0.08); border:1px solid rgba(245,158,11,0.35); border-radius:12px; padding:16px 20px; margin-bottom:20px;">
|
<div style="background:rgba(245,158,11,0.08); border:1px solid rgba(245,158,11,0.35); border-radius:12px; padding:16px 20px; margin-bottom:20px;">
|
||||||
<div style="font-weight:600; font-size:0.9rem; margin-bottom:12px;">⚠ Live price unavailable for {{len $d.MissingPrices}} holding{{if gt (len $d.MissingPrices) 1}}s{{end}} — add a Yahoo Finance ticker to fix this</div>
|
<div style="font-weight:600; font-size:0.9rem; margin-bottom:12px;">⚠ {{$d.T.Get "portfolio.missing_prices_warn"}}</div>
|
||||||
{{range $d.MissingPrices}}
|
{{range $d.MissingPrices}}
|
||||||
<form method="post" action="/portfolio/ticker" style="display:flex; align-items:center; gap:8px; margin-bottom:8px;">
|
<form method="post" action="/portfolio/ticker" style="display:flex; align-items:center; gap:8px; margin-bottom:8px;">
|
||||||
<input type="hidden" name="isin" value="{{.}}">
|
<input type="hidden" name="isin" value="{{.}}">
|
||||||
<code style="font-size:0.8rem; color:var(--muted); min-width:140px;">{{.}}</code>
|
<code style="font-size:0.8rem; color:var(--muted); min-width:140px;">{{.}}</code>
|
||||||
<input type="text" name="ticker" placeholder="e.g. QDVE.DE" required
|
<input type="text" name="ticker" placeholder="{{$d.T.Get "portfolio.ticker_placeholder"}}" required
|
||||||
style="padding:6px 10px; border:1px solid var(--border); border-radius:8px; background:var(--bg); color:var(--text); font-size:0.85rem; width:130px;">
|
style="padding:6px 10px; border:1px solid var(--border); border-radius:8px; background:var(--bg); color:var(--text); font-size:0.85rem; width:130px;">
|
||||||
<button type="submit" style="padding:6px 14px; background:var(--accent); color:#fff; border:none; border-radius:8px; font-size:0.85rem; font-weight:600; cursor:pointer;">Save</button>
|
<button type="submit" style="padding:6px 14px; background:var(--accent); color:#fff; border:none; border-radius:8px; font-size:0.85rem; font-weight:600; cursor:pointer;">{{$d.T.Get "portfolio.btn_save_ticker"}}</button>
|
||||||
<a href="https://finance.yahoo.com/lookup/" target="_blank" rel="noopener"
|
<a href="https://finance.yahoo.com/lookup/" target="_blank" rel="noopener"
|
||||||
style="font-size:0.78rem; color:var(--accent);">Look up ↗</a>
|
style="font-size:0.78rem; color:var(--accent);">{{$d.T.Get "portfolio.lookup_link"}}</a>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
@ -23,17 +23,17 @@
|
|||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Total Value</h2>
|
<h2>{{$d.T.Get "portfolio.cards.total_value"}}</h2>
|
||||||
<div class="value {{if ge $d.TotalPCLCents 0}}positive{{else}}negative{{end}} animate-counter"
|
<div class="value {{if ge $d.TotalPCLCents 0}}positive{{else}}negative{{end}} animate-counter"
|
||||||
data-target="{{$d.TotalValueCents}}" data-prefix="€">€0.00</div>
|
data-target="{{$d.TotalValueCents}}" data-prefix="€">€0.00</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Total Cost</h2>
|
<h2>{{$d.T.Get "portfolio.cards.total_cost"}}</h2>
|
||||||
<div class="value animate-counter" data-target="{{$d.TotalCostCents}}" data-prefix="€"
|
<div class="value animate-counter" data-target="{{$d.TotalCostCents}}" data-prefix="€"
|
||||||
style="color:var(--text);">€0.00</div>
|
style="color:var(--text);">€0.00</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Unrealized P&L</h2>
|
<h2>{{$d.T.Get "portfolio.cards.unrealized_pl"}}</h2>
|
||||||
<div class="value {{if ge $d.TotalPCLCents 0}}positive{{else}}negative{{end}} animate-counter"
|
<div class="value {{if ge $d.TotalPCLCents 0}}positive{{else}}negative{{end}} animate-counter"
|
||||||
data-target="{{$d.TotalPCLCents}}" data-prefix="€">€0.00</div>
|
data-target="{{$d.TotalPCLCents}}" data-prefix="€">€0.00</div>
|
||||||
<p style="font-size:13px; margin-top:6px; color:{{if eq (pctSign $d.TotalPCLPct) "+"}}var(--green){{else}}var(--red){{end}};">
|
<p style="font-size:13px; margin-top:6px; color:{{if eq (pctSign $d.TotalPCLPct) "+"}}var(--green){{else}}var(--red){{end}};">
|
||||||
@ -45,23 +45,23 @@
|
|||||||
<div class="grid-2" style="align-items:start;">
|
<div class="grid-2" style="align-items:start;">
|
||||||
<!-- Allocation donut -->
|
<!-- Allocation donut -->
|
||||||
<div class="card animate-on-scroll">
|
<div class="card animate-on-scroll">
|
||||||
<h2 style="margin-bottom:16px;">Allocation</h2>
|
<h2 style="margin-bottom:16px;">{{$d.T.Get "portfolio.allocation.section_title"}}</h2>
|
||||||
<div id="allocation3d" style="width:100%; height:380px; position:relative;"></div>
|
<div id="allocation3d" style="width:100%; height:380px; position:relative;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Holdings table -->
|
<!-- Holdings table -->
|
||||||
<div class="card animate-on-scroll">
|
<div class="card animate-on-scroll">
|
||||||
<h2 style="margin-bottom:14px;">Holdings</h2>
|
<h2 style="margin-bottom:14px;">{{$d.T.Get "portfolio.holdings.section_title"}}</h2>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Asset</th>
|
<th>{{$d.T.Get "portfolio.holdings.col_asset"}}</th>
|
||||||
<th class="text-right">Shares</th>
|
<th class="text-right">{{$d.T.Get "portfolio.holdings.col_shares"}}</th>
|
||||||
<th class="text-right">Avg Cost</th>
|
<th class="text-right">{{$d.T.Get "portfolio.holdings.col_avg_cost"}}</th>
|
||||||
<th class="text-right">Price</th>
|
<th class="text-right">{{$d.T.Get "portfolio.holdings.col_price"}}</th>
|
||||||
<th class="text-right">Value</th>
|
<th class="text-right">{{$d.T.Get "portfolio.holdings.col_value"}}</th>
|
||||||
<th class="text-right">P&L</th>
|
<th class="text-right">{{$d.T.Get "portfolio.holdings.col_pl"}}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -83,7 +83,6 @@
|
|||||||
{{if ge .UnrealizedPCLCents 0}}+{{else}}−{{end}}€{{cents (centsAbs .UnrealizedPCLCents)}}
|
{{if ge .UnrealizedPCLCents 0}}+{{else}}−{{end}}€{{cents (centsAbs .UnrealizedPCLCents)}}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -91,8 +90,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-top:16px; padding-top:14px; border-top:1px solid var(--border); display:flex; justify-content:space-between; align-items:center;">
|
<div style="margin-top:16px; padding-top:14px; border-top:1px solid var(--border); display:flex; justify-content:space-between; align-items:center;">
|
||||||
<span class="text-muted">Add trades via</span>
|
<span class="text-muted">{{$d.T.Get "portfolio.holdings.add_trades_via"}}</span>
|
||||||
<a href="/import" class="btn btn-outline btn-sm">Import CSV</a>
|
<a href="/import" class="btn btn-outline btn-sm">{{$d.T.Get "portfolio.holdings.btn_import"}}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -144,7 +143,6 @@ controls.autoRotateSpeed = 1.2;
|
|||||||
controls.minDistance = 5;
|
controls.minDistance = 5;
|
||||||
controls.maxDistance = 16;
|
controls.maxDistance = 16;
|
||||||
|
|
||||||
// tooltip
|
|
||||||
const tip = Object.assign(document.createElement('div'), {
|
const tip = Object.assign(document.createElement('div'), {
|
||||||
style: 'position:absolute;background:rgba(15,17,23,0.92);color:#e8eaf6;padding:7px 13px;border-radius:8px;font-size:13px;pointer-events:none;opacity:0;transition:opacity .15s;z-index:10;border:1px solid rgba(255,255,255,0.08);backdrop-filter:blur(8px);'
|
style: 'position:absolute;background:rgba(15,17,23,0.92);color:#e8eaf6;padding:7px 13px;border-radius:8px;font-size:13px;pointer-events:none;opacity:0;transition:opacity .15s;z-index:10;border:1px solid rgba(255,255,255,0.08);backdrop-filter:blur(8px);'
|
||||||
});
|
});
|
||||||
@ -236,9 +234,9 @@ window.addEventListener('resize', () => {
|
|||||||
{{else}}
|
{{else}}
|
||||||
<div class="card empty-state animate-on-scroll">
|
<div class="card empty-state animate-on-scroll">
|
||||||
<div style="font-size:48px; margin-bottom:16px;">📈</div>
|
<div style="font-size:48px; margin-bottom:16px;">📈</div>
|
||||||
<h3>No trades yet</h3>
|
<h3>{{$d.T.Get "portfolio.empty.title"}}</h3>
|
||||||
<p style="margin-bottom:20px;">Import your Trade Republic securities CSV to see your portfolio.</p>
|
<p style="margin-bottom:20px;">{{$d.T.Get "portfolio.empty.desc"}}</p>
|
||||||
<a href="/import" class="btn btn-primary">Import Trades</a>
|
<a href="/import" class="btn btn-primary">{{$d.T.Get "portfolio.empty.btn_import"}}</a>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@ -1,25 +1,25 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
{{$d := .}}
|
{{$d := .}}
|
||||||
<h1 style="margin-bottom:24px;">Projections</h1>
|
<h1 style="margin-bottom:24px;">{{$d.T.Get "projections.title"}}</h1>
|
||||||
|
|
||||||
{{if $d.Projections}}
|
{{if $d.Projections}}
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Projected Annual Spend</h2>
|
<h2>{{$d.T.Get "projections.card_annual_spend"}}</h2>
|
||||||
<div class="value negative animate-counter" data-target="{{$d.AnnualTotal}}" data-prefix="€">€0.00</div>
|
<div class="value negative animate-counter" data-target="{{$d.AnnualTotal}}" data-prefix="€">€0.00</div>
|
||||||
<p class="text-muted" style="margin-top:8px; font-size:12px;">Based on 6-month average</p>
|
<p class="text-muted" style="margin-top:8px; font-size:12px;">{{$d.T.Get "projections.card_annual_sub"}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Projected Monthly Spend</h2>
|
<h2>{{$d.T.Get "projections.card_monthly_spend"}}</h2>
|
||||||
{{$monthly := div $d.AnnualTotal 12}}
|
{{$monthly := div $d.AnnualTotal 12}}
|
||||||
<div class="value negative animate-counter" data-target="{{$monthly}}" data-prefix="€">€0.00</div>
|
<div class="value negative animate-counter" data-target="{{$monthly}}" data-prefix="€">€0.00</div>
|
||||||
<p class="text-muted" style="margin-top:8px; font-size:12px;">Average across all categories</p>
|
<p class="text-muted" style="margin-top:8px; font-size:12px;">{{$d.T.Get "projections.card_monthly_sub"}}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card animate-on-scroll">
|
<div class="card animate-on-scroll">
|
||||||
<h2 style="margin-bottom:16px;">Monthly Average by Category — Last 6 Months</h2>
|
<h2 style="margin-bottom:16px;">{{$d.T.Get "projections.chart_title"}}</h2>
|
||||||
<div style="padding-top:4px;">
|
<div style="padding-top:4px;">
|
||||||
<canvas id="projChart" height="250"></canvas>
|
<canvas id="projChart" height="250"></canvas>
|
||||||
</div>
|
</div>
|
||||||
@ -30,10 +30,10 @@
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Category</th>
|
<th>{{$d.T.Get "projections.table_col_category"}}</th>
|
||||||
<th class="text-right">Monthly Avg</th>
|
<th class="text-right">{{$d.T.Get "projections.table_col_monthly_avg"}}</th>
|
||||||
<th class="text-right">Projected Annual</th>
|
<th class="text-right">{{$d.T.Get "projections.table_col_projected"}}</th>
|
||||||
<th style="width:180px;">Share of Spend</th>
|
<th style="width:180px;">{{$d.T.Get "projections.table_col_share"}}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -105,9 +105,9 @@ new Chart(document.getElementById('projChart'), {
|
|||||||
{{else}}
|
{{else}}
|
||||||
<div class="card empty-state animate-on-scroll">
|
<div class="card empty-state animate-on-scroll">
|
||||||
<div style="font-size:48px; margin-bottom:16px;">📊</div>
|
<div style="font-size:48px; margin-bottom:16px;">📊</div>
|
||||||
<h3>No spending data yet</h3>
|
<h3>{{$d.T.Get "projections.empty.title"}}</h3>
|
||||||
<p>Import at least a month of transactions to see projections.</p>
|
<p>{{$d.T.Get "projections.empty.desc"}}</p>
|
||||||
<a href="/import" class="btn btn-primary" style="margin-top:20px;">Import Transactions</a>
|
<a href="/import" class="btn btn-primary" style="margin-top:20px;">{{$d.T.Get "projections.empty.btn_import"}}</a>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@ -88,27 +88,27 @@
|
|||||||
<!-- ── Summary ── -->
|
<!-- ── Summary ── -->
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Total property value</h2>
|
<h2>{{$d.T.Get "property.summary_total_value"}}</h2>
|
||||||
<div class="value animate-counter" data-target="{{$d.TotalPropertyValueCents}}" data-prefix="€">€0</div>
|
<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>
|
<p style="font-size:12px;color:var(--text3);margin-top:4px;">{{$d.T.Get "property.summary_total_value_sub"}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Outstanding loans</h2>
|
<h2>{{$d.T.Get "property.summary_outstanding_loans"}}</h2>
|
||||||
<div class="value animate-counter" data-target="{{$d.TotalLoanBalanceCents}}" data-prefix="€" style="color:var(--red);">€0</div>
|
<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>
|
<p style="font-size:12px;color:var(--text3);margin-top:4px;">{{$d.T.Get "property.summary_outstanding_sub"}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Net equity</h2>
|
<h2>{{$d.T.Get "property.summary_net_equity"}}</h2>
|
||||||
<div class="value animate-counter" data-target="{{$d.TotalEquityCents}}" data-prefix="€"
|
<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>
|
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>
|
<p style="font-size:12px;color:var(--text3);margin-top:4px;">{{$d.T.Get "property.summary_net_equity_sub"}}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Properties ── -->
|
<!-- ── Properties ── -->
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<span class="section-title">Properties</span>
|
<span class="section-title">{{$d.T.Get "property.properties.section_title"}}</span>
|
||||||
<button class="btn btn-primary btn-sm" onclick="openModal('add-property-modal')">+ Add property</button>
|
<button class="btn btn-primary btn-sm" onclick="openModal('add-property-modal')">{{$d.T.Get "property.properties.btn_add"}}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if $d.Properties}}
|
{{if $d.Properties}}
|
||||||
@ -121,21 +121,21 @@
|
|||||||
|
|
||||||
<div class="prop-stats">
|
<div class="prop-stats">
|
||||||
<div class="prop-stat">
|
<div class="prop-stat">
|
||||||
<div class="prop-stat-label">Current value</div>
|
<div class="prop-stat-label">{{$d.T.Get "property.properties.stat_current_value"}}</div>
|
||||||
<div class="prop-stat-value">€{{cents .CurrentValueCents}}</div>
|
<div class="prop-stat-value">€{{cents .CurrentValueCents}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="prop-stat">
|
<div class="prop-stat">
|
||||||
<div class="prop-stat-label">Equity</div>
|
<div class="prop-stat-label">{{$d.T.Get "property.properties.stat_equity"}}</div>
|
||||||
<div class="prop-stat-value {{if ge .EquityCents 0}}positive{{else}}negative{{end}}">
|
<div class="prop-stat-value {{if ge .EquityCents 0}}positive{{else}}negative{{end}}">
|
||||||
€{{cents .EquityCents}}
|
€{{cents .EquityCents}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="prop-stat">
|
<div class="prop-stat">
|
||||||
<div class="prop-stat-label">Purchase price</div>
|
<div class="prop-stat-label">{{$d.T.Get "property.properties.stat_purchase_price"}}</div>
|
||||||
<div class="prop-stat-value">€{{cents .PurchasePriceCents}}</div>
|
<div class="prop-stat-value">€{{cents .PurchasePriceCents}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="prop-stat">
|
<div class="prop-stat">
|
||||||
<div class="prop-stat-label">Gain</div>
|
<div class="prop-stat-label">{{$d.T.Get "property.properties.stat_gain"}}</div>
|
||||||
<div class="prop-stat-value {{if ge .GainCents 0}}positive{{else}}negative{{end}}">
|
<div class="prop-stat-value {{if ge .GainCents 0}}positive{{else}}negative{{end}}">
|
||||||
{{printf "%.1f" .GainPct}}%
|
{{printf "%.1f" .GainPct}}%
|
||||||
</div>
|
</div>
|
||||||
@ -145,8 +145,8 @@
|
|||||||
{{if .LinkedLoan}}
|
{{if .LinkedLoan}}
|
||||||
<div class="equity-bar-wrap">
|
<div class="equity-bar-wrap">
|
||||||
<div class="equity-bar-label">
|
<div class="equity-bar-label">
|
||||||
<span>Equity {{.EquityPct}}%</span>
|
<span>{{$d.T.Get "property.properties.stat_equity"}} {{.EquityPct}}%</span>
|
||||||
<span>Loan {{.LoanPct}}%</span>
|
<span>{{$d.T.Get "property.loans.stat_balance"}} {{.LoanPct}}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="equity-bar">
|
<div class="equity-bar">
|
||||||
<div class="equity-bar-fill" style="width:{{.EquityPct}}%;"></div>
|
<div class="equity-bar-fill" style="width:{{.EquityPct}}%;"></div>
|
||||||
@ -154,10 +154,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="loan-mini">
|
<div class="loan-mini">
|
||||||
<div class="loan-mini-title">🏦 {{.LinkedLoan.Name}}</div>
|
<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>{{$d.T.Get "property.properties.loan_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>{{$d.T.Get "property.properties.loan_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>{{$d.T.Get "property.properties.loan_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-mini-row"><span>{{$d.T.Get "property.properties.loan_rate"}}</span><span>{{printf "%.2f" .LinkedLoan.InterestRatePct}}%</span></div>
|
||||||
<div class="loan-progress">
|
<div class="loan-progress">
|
||||||
<div class="loan-progress-fill" style="width:{{.LinkedLoan.PaidPct}}%;"></div>
|
<div class="loan-progress-fill" style="width:{{.LinkedLoan.PaidPct}}%;"></div>
|
||||||
</div>
|
</div>
|
||||||
@ -167,13 +167,13 @@
|
|||||||
<div class="prop-actions">
|
<div class="prop-actions">
|
||||||
<button class="btn btn-outline btn-sm"
|
<button class="btn btn-outline btn-sm"
|
||||||
onclick="openEditProperty('{{.ID}}','{{.Name}}','{{.Address}}',{{.PurchasePriceCents}},{{.CurrentValueCents}},{{printf "%.2f" .AppreciationPct}},'{{.PurchaseDate.Format "2006-01-02"}}','{{.Status}}','{{.Notes}}')">
|
onclick="openEditProperty('{{.ID}}','{{.Name}}','{{.Address}}',{{.PurchasePriceCents}},{{.CurrentValueCents}},{{printf "%.2f" .AppreciationPct}},'{{.PurchaseDate.Format "2006-01-02"}}','{{.Status}}','{{.Notes}}')">
|
||||||
Edit
|
{{$d.T.Get "property.properties.btn_edit"}}
|
||||||
</button>
|
</button>
|
||||||
<form method="POST" action="/property" style="display:inline;">
|
<form method="POST" action="/property" style="display:inline;">
|
||||||
<input type="hidden" name="action" value="delete_property">
|
<input type="hidden" name="action" value="delete_property">
|
||||||
<input type="hidden" name="id" value="{{.ID}}">
|
<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;"
|
<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>
|
onclick="return confirm('Remove {{.Name}}?')">{{$d.T.Get "property.properties.btn_remove"}}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -182,17 +182,17 @@
|
|||||||
{{else}}
|
{{else}}
|
||||||
<div class="card empty-state animate-on-scroll">
|
<div class="card empty-state animate-on-scroll">
|
||||||
<div style="font-size:48px;margin-bottom:16px;">🏠</div>
|
<div style="font-size:48px;margin-bottom:16px;">🏠</div>
|
||||||
<h3>No properties yet</h3>
|
<h3>{{$d.T.Get "property.properties.empty.title"}}</h3>
|
||||||
<p style="margin-bottom:20px;">Add your home, investment property, or land to track equity and loans in one place.</p>
|
<p style="margin-bottom:20px;">{{$d.T.Get "property.properties.empty.desc"}}</p>
|
||||||
<button class="btn btn-primary" onclick="openModal('add-property-modal')">Add your first property</button>
|
<button class="btn btn-primary" onclick="openModal('add-property-modal')">{{$d.T.Get "property.properties.empty.btn_add_first"}}</button>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<!-- ── Standalone loans ── -->
|
<!-- ── Standalone loans ── -->
|
||||||
{{if or $d.UnlinkedLoans (not $d.Properties)}}
|
{{if or $d.UnlinkedLoans (not $d.Properties)}}
|
||||||
<div class="section-header" style="margin-top:32px;">
|
<div class="section-header" style="margin-top:32px;">
|
||||||
<span class="section-title">Loans</span>
|
<span class="section-title">{{$d.T.Get "property.loans.section_title"}}</span>
|
||||||
<button class="btn btn-outline btn-sm" onclick="openModal('add-loan-modal')">+ Add loan</button>
|
<button class="btn btn-outline btn-sm" onclick="openModal('add-loan-modal')">{{$d.T.Get "property.loans.btn_add"}}</button>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
@ -205,27 +205,27 @@
|
|||||||
|
|
||||||
<div class="prop-stats" style="margin-top:12px;">
|
<div class="prop-stats" style="margin-top:12px;">
|
||||||
<div class="prop-stat">
|
<div class="prop-stat">
|
||||||
<div class="prop-stat-label">Balance</div>
|
<div class="prop-stat-label">{{$d.T.Get "property.loans.stat_balance"}}</div>
|
||||||
<div class="prop-stat-value negative">€{{cents .BalanceCents}}</div>
|
<div class="prop-stat-value negative">€{{cents .BalanceCents}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="prop-stat">
|
<div class="prop-stat">
|
||||||
<div class="prop-stat-label">Monthly</div>
|
<div class="prop-stat-label">{{$d.T.Get "property.loans.stat_monthly"}}</div>
|
||||||
<div class="prop-stat-value">€{{cents .EffectiveMonthlyPaymentCents}}</div>
|
<div class="prop-stat-value">€{{cents .EffectiveMonthlyPaymentCents}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="prop-stat">
|
<div class="prop-stat">
|
||||||
<div class="prop-stat-label">Payoff</div>
|
<div class="prop-stat-label">{{$d.T.Get "property.loans.stat_payoff"}}</div>
|
||||||
<div class="prop-stat-value">{{.PayoffDate.Format "Jan 2006"}}</div>
|
<div class="prop-stat-value">{{.PayoffDate.Format "Jan 2006"}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="prop-stat">
|
<div class="prop-stat">
|
||||||
<div class="prop-stat-label">Rate</div>
|
<div class="prop-stat-label">{{$d.T.Get "property.loans.stat_rate"}}</div>
|
||||||
<div class="prop-stat-value">{{printf "%.2f" .InterestRatePct}}%</div>
|
<div class="prop-stat-value">{{printf "%.2f" .InterestRatePct}}%</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="equity-bar-wrap">
|
<div class="equity-bar-wrap">
|
||||||
<div class="equity-bar-label">
|
<div class="equity-bar-label">
|
||||||
<span>Paid {{.PaidPct}}%</span>
|
<span>{{$d.T.Get "property.loans.stat_monthly"}} {{.PaidPct}}%</span>
|
||||||
<span>€{{cents .TotalRemainingInterestCents}} interest left</span>
|
<span>€{{cents .TotalRemainingInterestCents}} {{$d.T.Get "property.loans.interest_left"}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="equity-bar">
|
<div class="equity-bar">
|
||||||
<div class="equity-bar-fill" style="width:{{.PaidPct}}%;"></div>
|
<div class="equity-bar-fill" style="width:{{.PaidPct}}%;"></div>
|
||||||
@ -237,13 +237,13 @@
|
|||||||
<input type="hidden" name="action" value="payoff_loan">
|
<input type="hidden" name="action" value="payoff_loan">
|
||||||
<input type="hidden" name="id" value="{{.ID}}">
|
<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;"
|
<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>
|
onclick="return confirm('{{$d.T.Get "property.loans.confirm_paid_off"}}')">{{$d.T.Get "property.loans.btn_mark_paid"}}</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/property" style="display:inline;">
|
<form method="POST" action="/property" style="display:inline;">
|
||||||
<input type="hidden" name="action" value="delete_loan">
|
<input type="hidden" name="action" value="delete_loan">
|
||||||
<input type="hidden" name="id" value="{{.ID}}">
|
<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;"
|
<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>
|
onclick="return confirm('Remove {{.Name}}?')">{{$d.T.Get "property.loans.btn_remove"}}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -253,60 +253,60 @@
|
|||||||
|
|
||||||
{{if $d.Properties}}
|
{{if $d.Properties}}
|
||||||
<div style="margin-top:24px;">
|
<div style="margin-top:24px;">
|
||||||
<button class="btn btn-outline btn-sm" onclick="openModal('add-loan-modal')">+ Add loan</button>
|
<button class="btn btn-outline btn-sm" onclick="openModal('add-loan-modal')">{{$d.T.Get "property.loans.btn_add"}}</button>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<!-- ── Add Property Modal ── -->
|
<!-- ── Add Property Modal ── -->
|
||||||
<div id="add-property-modal" class="modal-overlay" onclick="if(event.target===this)closeModal('add-property-modal')">
|
<div id="add-property-modal" class="modal-overlay" onclick="if(event.target===this)closeModal('add-property-modal')">
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<p class="modal-title">Add property</p>
|
<p class="modal-title">{{$d.T.Get "property.modal_add.title_property"}}</p>
|
||||||
<form method="POST" action="/property">
|
<form method="POST" action="/property">
|
||||||
<input type="hidden" name="action" value="add_property">
|
<input type="hidden" name="action" value="add_property">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Property name *</label>
|
<label>{{$d.T.Get "property.modal_add.label_name"}}</label>
|
||||||
<input type="text" name="name" placeholder="e.g. Main House" required>
|
<input type="text" name="name" placeholder="{{$d.T.Get "property.modal_add.placeholder_name"}}" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Address</label>
|
<label>{{$d.T.Get "property.modal_add.label_address"}}</label>
|
||||||
<input type="text" name="address" placeholder="Street, city">
|
<input type="text" name="address" placeholder="{{$d.T.Get "property.modal_add.placeholder_address"}}">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Purchase price (€) *</label>
|
<label>{{$d.T.Get "property.modal_add.label_purchase_price"}}</label>
|
||||||
<input type="number" name="purchase_price" placeholder="220000" step="0.01" required>
|
<input type="number" name="purchase_price" placeholder="{{$d.T.Get "property.modal_add.placeholder_purchase_price"}}" step="0.01" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Current value (€)</label>
|
<label>{{$d.T.Get "property.modal_add.label_current_value"}}</label>
|
||||||
<input type="number" name="current_value" placeholder="Same as purchase" step="0.01">
|
<input type="number" name="current_value" placeholder="{{$d.T.Get "property.modal_add.placeholder_current_value"}}" step="0.01">
|
||||||
<span class="form-hint">Leave blank to use purchase price</span>
|
<span class="form-hint">{{$d.T.Get "property.modal_add.hint_current_value"}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Purchase date</label>
|
<label>{{$d.T.Get "property.modal_add.label_purchase_date"}}</label>
|
||||||
<input type="date" name="purchase_date">
|
<input type="date" name="purchase_date">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Est. appreciation (%/year)</label>
|
<label>{{$d.T.Get "property.modal_add.label_appreciation"}}</label>
|
||||||
<input type="number" name="appreciation_pct" placeholder="2.0" step="0.1" value="2.0">
|
<input type="number" name="appreciation_pct" placeholder="{{$d.T.Get "property.modal_add.placeholder_appreciation"}}" step="0.1" value="2.0">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Status</label>
|
<label>{{$d.T.Get "property.modal_add.label_status"}}</label>
|
||||||
<select name="status">
|
<select name="status">
|
||||||
<option value="owned">Owned</option>
|
<option value="owned">{{$d.T.Get "property.modal_add.status_owned"}}</option>
|
||||||
<option value="building">Under construction</option>
|
<option value="building">{{$d.T.Get "property.modal_add.status_building"}}</option>
|
||||||
<option value="sold">Sold</option>
|
<option value="sold">{{$d.T.Get "property.modal_add.status_sold"}}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Notes</label>
|
<label>{{$d.T.Get "property.modal_add.label_notes"}}</label>
|
||||||
<textarea name="notes" rows="2" placeholder="Optional notes"></textarea>
|
<textarea name="notes" rows="2" placeholder="{{$d.T.Get "property.modal_add.placeholder_notes"}}"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline" onclick="closeModal('add-property-modal')">Cancel</button>
|
<button type="button" class="btn btn-outline" onclick="closeModal('add-property-modal')">{{$d.T.Get "property.modal_add.btn_cancel"}}</button>
|
||||||
<button type="submit" class="btn btn-primary">Add property</button>
|
<button type="submit" class="btn btn-primary">{{$d.T.Get "property.modal_add.btn_add"}}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@ -315,53 +315,53 @@
|
|||||||
<!-- ── Edit Property Modal ── -->
|
<!-- ── Edit Property Modal ── -->
|
||||||
<div id="edit-property-modal" class="modal-overlay" onclick="if(event.target===this)closeModal('edit-property-modal')">
|
<div id="edit-property-modal" class="modal-overlay" onclick="if(event.target===this)closeModal('edit-property-modal')">
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<p class="modal-title">Edit property</p>
|
<p class="modal-title">{{$d.T.Get "property.modal_edit.title_property"}}</p>
|
||||||
<form method="POST" action="/property" id="edit-property-form">
|
<form method="POST" action="/property" id="edit-property-form">
|
||||||
<input type="hidden" name="action" value="update_property">
|
<input type="hidden" name="action" value="update_property">
|
||||||
<input type="hidden" name="id" id="edit-prop-id">
|
<input type="hidden" name="id" id="edit-prop-id">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Property name *</label>
|
<label>{{$d.T.Get "property.modal_edit.label_name"}}</label>
|
||||||
<input type="text" name="name" id="edit-prop-name" required>
|
<input type="text" name="name" id="edit-prop-name" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Address</label>
|
<label>{{$d.T.Get "property.modal_edit.label_address"}}</label>
|
||||||
<input type="text" name="address" id="edit-prop-addr">
|
<input type="text" name="address" id="edit-prop-addr">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Purchase price (€)</label>
|
<label>{{$d.T.Get "property.modal_edit.label_purchase_price"}}</label>
|
||||||
<input type="number" name="purchase_price" id="edit-prop-purchase" step="0.01">
|
<input type="number" name="purchase_price" id="edit-prop-purchase" step="0.01">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Current value (€)</label>
|
<label>{{$d.T.Get "property.modal_edit.label_current_value"}}</label>
|
||||||
<input type="number" name="current_value" id="edit-prop-value" step="0.01">
|
<input type="number" name="current_value" id="edit-prop-value" step="0.01">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Purchase date</label>
|
<label>{{$d.T.Get "property.modal_edit.label_purchase_date"}}</label>
|
||||||
<input type="date" name="purchase_date" id="edit-prop-date">
|
<input type="date" name="purchase_date" id="edit-prop-date">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Appreciation (%/year)</label>
|
<label>{{$d.T.Get "property.modal_edit.label_appreciation"}}</label>
|
||||||
<input type="number" name="appreciation_pct" id="edit-prop-appr" step="0.1">
|
<input type="number" name="appreciation_pct" id="edit-prop-appr" step="0.1">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Status</label>
|
<label>{{$d.T.Get "property.modal_edit.label_status"}}</label>
|
||||||
<select name="status" id="edit-prop-status">
|
<select name="status" id="edit-prop-status">
|
||||||
<option value="owned">Owned</option>
|
<option value="owned">{{$d.T.Get "property.modal_edit.status_owned"}}</option>
|
||||||
<option value="building">Under construction</option>
|
<option value="building">{{$d.T.Get "property.modal_edit.status_building"}}</option>
|
||||||
<option value="sold">Sold</option>
|
<option value="sold">{{$d.T.Get "property.modal_edit.status_sold"}}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Notes</label>
|
<label>{{$d.T.Get "property.modal_edit.label_notes"}}</label>
|
||||||
<textarea name="notes" id="edit-prop-notes" rows="2"></textarea>
|
<textarea name="notes" id="edit-prop-notes" rows="2"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline" onclick="closeModal('edit-property-modal')">Cancel</button>
|
<button type="button" class="btn btn-outline" onclick="closeModal('edit-property-modal')">{{$d.T.Get "property.modal_edit.btn_cancel"}}</button>
|
||||||
<button type="submit" class="btn btn-primary">Save changes</button>
|
<button type="submit" class="btn btn-primary">{{$d.T.Get "property.modal_edit.btn_save"}}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@ -370,26 +370,26 @@
|
|||||||
<!-- ── Add Loan Modal ── -->
|
<!-- ── Add Loan Modal ── -->
|
||||||
<div id="add-loan-modal" class="modal-overlay" onclick="if(event.target===this)closeModal('add-loan-modal')">
|
<div id="add-loan-modal" class="modal-overlay" onclick="if(event.target===this)closeModal('add-loan-modal')">
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<p class="modal-title">Add loan</p>
|
<p class="modal-title">{{$d.T.Get "property.modal_loan.title_loan"}}</p>
|
||||||
<form method="POST" action="/property">
|
<form method="POST" action="/property">
|
||||||
<input type="hidden" name="action" value="add_loan">
|
<input type="hidden" name="action" value="add_loan">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Loan name *</label>
|
<label>{{$d.T.Get "property.modal_loan.label_name"}}</label>
|
||||||
<input type="text" name="name" placeholder="e.g. Home mortgage" required>
|
<input type="text" name="name" placeholder="{{$d.T.Get "property.modal_loan.placeholder_name"}}" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Type</label>
|
<label>{{$d.T.Get "property.modal_loan.label_type"}}</label>
|
||||||
<select name="loan_type">
|
<select name="loan_type">
|
||||||
<option value="mortgage">Mortgage</option>
|
<option value="mortgage">{{$d.T.Get "property.modal_loan.type_mortgage"}}</option>
|
||||||
<option value="construction">Construction loan</option>
|
<option value="construction">{{$d.T.Get "property.modal_loan.type_construction"}}</option>
|
||||||
<option value="personal">Personal loan</option>
|
<option value="personal">{{$d.T.Get "property.modal_loan.type_personal"}}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Linked property</label>
|
<label>{{$d.T.Get "property.modal_loan.label_linked_property"}}</label>
|
||||||
<select name="property_id">
|
<select name="property_id">
|
||||||
<option value="">— none —</option>
|
<option value="">{{$d.T.Get "property.modal_loan.option_none_property"}}</option>
|
||||||
{{range $d.Properties}}
|
{{range $d.Properties}}
|
||||||
<option value="{{.ID}}">{{.Name}}</option>
|
<option value="{{.ID}}">{{.Name}}</option>
|
||||||
{{end}}
|
{{end}}
|
||||||
@ -398,44 +398,44 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Original principal (€) *</label>
|
<label>{{$d.T.Get "property.modal_loan.label_principal"}}</label>
|
||||||
<input type="number" name="principal" placeholder="200000" step="0.01" required>
|
<input type="number" name="principal" placeholder="{{$d.T.Get "property.modal_loan.placeholder_principal"}}" step="0.01" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Current balance (€)</label>
|
<label>{{$d.T.Get "property.modal_loan.label_balance"}}</label>
|
||||||
<input type="number" name="balance" placeholder="Same as principal" step="0.01">
|
<input type="number" name="balance" placeholder="{{$d.T.Get "property.modal_loan.placeholder_balance"}}" step="0.01">
|
||||||
<span class="form-hint">Leave blank if just starting</span>
|
<span class="form-hint">{{$d.T.Get "property.modal_loan.hint_balance"}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Interest rate (%/year) *</label>
|
<label>{{$d.T.Get "property.modal_loan.label_interest_rate"}}</label>
|
||||||
<input type="number" name="interest_rate" placeholder="3.2" step="0.01" required>
|
<input type="number" name="interest_rate" placeholder="{{$d.T.Get "property.modal_loan.placeholder_rate"}}" step="0.01" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Term (months) *</label>
|
<label>{{$d.T.Get "property.modal_loan.label_term"}}</label>
|
||||||
<input type="number" name="term_months" placeholder="360" required>
|
<input type="number" name="term_months" placeholder="{{$d.T.Get "property.modal_loan.placeholder_term"}}" required>
|
||||||
<span class="form-hint">e.g. 360 = 30 years</span>
|
<span class="form-hint">{{$d.T.Get "property.modal_loan.hint_term"}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Monthly payment (€)</label>
|
<label>{{$d.T.Get "property.modal_loan.label_monthly_payment"}}</label>
|
||||||
<input type="number" name="monthly_payment" placeholder="Auto-computed" step="0.01">
|
<input type="number" name="monthly_payment" placeholder="{{$d.T.Get "property.modal_loan.placeholder_monthly_payment"}}" step="0.01">
|
||||||
<span class="form-hint">Leave blank to calculate</span>
|
<span class="form-hint">{{$d.T.Get "property.modal_loan.hint_monthly_payment"}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Start date</label>
|
<label>{{$d.T.Get "property.modal_loan.label_start_date"}}</label>
|
||||||
<input type="date" name="start_date">
|
<input type="date" name="start_date">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Notes</label>
|
<label>{{$d.T.Get "property.modal_loan.label_notes"}}</label>
|
||||||
<textarea name="notes" rows="2" placeholder="Bank name, reference, etc."></textarea>
|
<textarea name="notes" rows="2" placeholder="{{$d.T.Get "property.modal_loan.placeholder_notes"}}"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline" onclick="closeModal('add-loan-modal')">Cancel</button>
|
<button type="button" class="btn btn-outline" onclick="closeModal('add-loan-modal')">{{$d.T.Get "property.modal_loan.btn_cancel"}}</button>
|
||||||
<button type="submit" class="btn btn-primary">Add loan</button>
|
<button type="submit" class="btn btn-primary">{{$d.T.Get "property.modal_loan.btn_add"}}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,28 +1,28 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
{{$d := .}}
|
{{$d := .}}
|
||||||
<h1 style="margin-bottom:24px;">Monthly Reports</h1>
|
<h1 style="margin-bottom:24px;">{{$d.T.Get "reports.title"}}</h1>
|
||||||
|
|
||||||
<div class="card animate-on-scroll">
|
<div class="card animate-on-scroll">
|
||||||
<h2>12-Month Spend by Category</h2>
|
<h2>{{$d.T.Get "reports.chart_title"}}</h2>
|
||||||
<div style="padding-top:8px;">
|
<div style="padding-top:8px;">
|
||||||
<canvas id="reportChart" height="280"></canvas>
|
<canvas id="reportChart" height="280"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card animate-on-scroll" style="overflow-x:auto;">
|
<div class="card animate-on-scroll" style="overflow-x:auto;">
|
||||||
<h2 style="margin-bottom:14px;">Breakdown by Month</h2>
|
<h2 style="margin-bottom:14px;">{{$d.T.Get "reports.table_title"}}</h2>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Month</th>
|
<th>{{$d.T.Get "reports.col_month"}}</th>
|
||||||
{{range $cat, $_ := $d.CategoryNames}}
|
{{range $cat, $_ := $d.CategoryNames}}
|
||||||
{{$color := index $d.CategoryColors $cat}}
|
{{$color := index $d.CategoryColors $cat}}
|
||||||
<th class="text-right" style="white-space:nowrap;">
|
<th class="text-right" style="white-space:nowrap;">
|
||||||
{{if $color}}<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:{{$color}};margin-right:4px;vertical-align:middle;box-shadow:0 0 4px {{$color}}88;"></span>{{end}}{{$cat}}
|
{{if $color}}<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:{{$color}};margin-right:4px;vertical-align:middle;box-shadow:0 0 4px {{$color}}88;"></span>{{end}}{{$cat}}
|
||||||
</th>
|
</th>
|
||||||
{{end}}
|
{{end}}
|
||||||
<th class="text-right">Total</th>
|
<th class="text-right">{{$d.T.Get "reports.col_total"}}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@ -24,27 +24,27 @@ tr:last-child td { border-bottom:none; }
|
|||||||
.empty-state { padding:32px; text-align:center; color:var(--muted); font-size:0.875rem; }
|
.empty-state { padding:32px; text-align:center; color:var(--muted); font-size:0.875rem; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<h1 style="margin:0 0 20px;">Settings</h1>
|
<h1 style="margin:0 0 20px;">{{$d.T.Get "settings.title"}}</h1>
|
||||||
|
|
||||||
<div class="tab-bar">
|
<div class="tab-bar">
|
||||||
<a href="/settings?tab=accounts" class="{{if eq $d.Tab "accounts"}}active{{end}}">Accounts</a>
|
<a href="/settings?tab=accounts" class="{{if eq $d.Tab "accounts"}}active{{end}}">{{$d.T.Get "settings.tab_accounts"}}</a>
|
||||||
<a href="/settings?tab=categories" class="{{if eq $d.Tab "categories"}}active{{end}}">Categories</a>
|
<a href="/settings?tab=categories" class="{{if eq $d.Tab "categories"}}active{{end}}">{{$d.T.Get "settings.tab_categories"}}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if eq $d.Tab "accounts"}}
|
{{if eq $d.Tab "accounts"}}
|
||||||
|
|
||||||
<div class="s-card">
|
<div class="s-card">
|
||||||
<h3>Add account</h3>
|
<h3>{{$d.T.Get "settings.accounts.card_add_title"}}</h3>
|
||||||
<form method="post" action="/accounts" id="add-account-form">
|
<form method="post" action="/accounts" id="add-account-form">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<input type="text" name="name" placeholder="Account name" required>
|
<input type="text" name="name" placeholder="{{$d.T.Get "settings.accounts.placeholder_name"}}" required>
|
||||||
<select name="type">
|
<select name="type">
|
||||||
<option value="checking">Checking</option>
|
<option value="checking">{{$d.T.Get "settings.accounts.type_checking"}}</option>
|
||||||
<option value="savings">Savings</option>
|
<option value="savings">{{$d.T.Get "settings.accounts.type_savings"}}</option>
|
||||||
<option value="credit">Credit card</option>
|
<option value="credit">{{$d.T.Get "settings.accounts.type_credit"}}</option>
|
||||||
<option value="securities">Securities</option>
|
<option value="securities">{{$d.T.Get "settings.accounts.type_securities"}}</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="submit" class="btn btn-primary">Add</button>
|
<button type="submit" class="btn btn-primary">{{$d.T.Get "settings.accounts.btn_add"}}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@ -52,34 +52,34 @@ tr:last-child td { border-bottom:none; }
|
|||||||
<div class="s-card">
|
<div class="s-card">
|
||||||
{{if $d.Accounts}}
|
{{if $d.Accounts}}
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Name</th><th>Type</th><th></th></tr></thead>
|
<thead><tr><th>{{$d.T.Get "settings.accounts.col_name"}}</th><th>{{$d.T.Get "settings.accounts.col_type"}}</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range $d.Accounts}}
|
{{range $d.Accounts}}
|
||||||
<tr>
|
<tr>
|
||||||
<td style="font-weight:500;">{{.Name}}</td>
|
<td style="font-weight:500;">{{.Name}}</td>
|
||||||
<td><span class="type-badge">{{.Type}}</span></td>
|
<td><span class="type-badge">{{.Type}}</span></td>
|
||||||
<td style="text-align:right;">
|
<td style="text-align:right;">
|
||||||
<button class="btn btn-danger" onclick="deleteAccount('{{.ID}}')">Delete</button>
|
<button class="btn btn-danger" onclick="deleteAccount('{{.ID}}')">{{$d.T.Get "settings.accounts.btn_delete"}}</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="empty-state">No accounts yet — add one above.</div>
|
<div class="empty-state">{{$d.T.Get "settings.accounts.empty_msg"}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{else}}{{/* categories tab */}}
|
{{else}}{{/* categories tab */}}
|
||||||
|
|
||||||
<div class="s-card">
|
<div class="s-card">
|
||||||
<h3>Add category</h3>
|
<h3>{{$d.T.Get "settings.categories.card_add_title"}}</h3>
|
||||||
<form method="post" action="/categories" id="add-cat-form">
|
<form method="post" action="/categories" id="add-cat-form">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<input type="text" name="name" placeholder="Category name" required>
|
<input type="text" name="name" placeholder="{{$d.T.Get "settings.categories.placeholder_name"}}" required>
|
||||||
<input type="number" name="budget_cents" placeholder="Monthly budget (cents)" min="0">
|
<input type="number" name="budget_cents" placeholder="{{$d.T.Get "settings.categories.placeholder_budget"}}" min="0">
|
||||||
<input type="color" name="color" value="#6366f1" style="flex:0; width:44px; padding:4px; border-radius:8px; cursor:pointer;">
|
<input type="color" name="color" value="#6366f1" style="flex:0; width:44px; padding:4px; border-radius:8px; cursor:pointer;">
|
||||||
<button type="submit" class="btn btn-primary">Add</button>
|
<button type="submit" class="btn btn-primary">{{$d.T.Get "settings.categories.btn_add"}}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@ -87,7 +87,7 @@ tr:last-child td { border-bottom:none; }
|
|||||||
<div class="s-card">
|
<div class="s-card">
|
||||||
{{if $d.Categories}}
|
{{if $d.Categories}}
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Category</th><th style="text-align:right;">Monthly Budget</th><th></th></tr></thead>
|
<thead><tr><th>{{$d.T.Get "settings.categories.col_category"}}</th><th style="text-align:right;">{{$d.T.Get "settings.categories.col_budget"}}</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range $d.Categories}}
|
{{range $d.Categories}}
|
||||||
<tr id="cat-row-{{.ID}}">
|
<tr id="cat-row-{{.ID}}">
|
||||||
@ -105,14 +105,14 @@ tr:last-child td { border-bottom:none; }
|
|||||||
style="margin-left:8px; background:none; border:none; cursor:pointer; color:var(--muted); font-size:0.85rem;">✎</button>
|
style="margin-left:8px; background:none; border:none; cursor:pointer; color:var(--muted); font-size:0.85rem;">✎</button>
|
||||||
</td>
|
</td>
|
||||||
<td style="text-align:right;">
|
<td style="text-align:right;">
|
||||||
<button class="btn btn-danger" onclick="deleteCat('{{.ID}}')">Delete</button>
|
<button class="btn btn-danger" onclick="deleteCat('{{.ID}}')">{{$d.T.Get "settings.categories.btn_delete"}}</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="empty-state">No categories yet — add one above.</div>
|
<div class="empty-state">{{$d.T.Get "settings.categories.empty_msg"}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -122,32 +122,34 @@ tr:last-child td { border-bottom:none; }
|
|||||||
<div id="edit-cat-modal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.5); backdrop-filter:blur(4px); z-index:300; align-items:center; justify-content:center;">
|
<div id="edit-cat-modal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.5); backdrop-filter:blur(4px); z-index:300; align-items:center; justify-content:center;">
|
||||||
<div class="s-card" style="width:380px; max-width:95vw; margin:0; box-shadow:var(--shadow-lg);">
|
<div class="s-card" style="width:380px; max-width:95vw; margin:0; box-shadow:var(--shadow-lg);">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
||||||
<span style="font-weight:700;">Edit Category</span>
|
<span style="font-weight:700;">{{$d.T.Get "settings.categories.modal_edit.title"}}</span>
|
||||||
<button onclick="closeEditCat()" style="background:none; border:none; cursor:pointer; color:var(--muted); font-size:1.1rem;">✕</button>
|
<button onclick="closeEditCat()" style="background:none; border:none; cursor:pointer; color:var(--muted); font-size:1.1rem;">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex; flex-direction:column; gap:10px;">
|
<div style="display:flex; flex-direction:column; gap:10px;">
|
||||||
<input id="edit-cat-name" type="text" placeholder="Name" style="padding:8px 12px; border:1px solid var(--border); border-radius:8px; background:var(--bg); color:var(--text);">
|
<input id="edit-cat-name" type="text" placeholder="{{$d.T.Get "settings.categories.modal_edit.placeholder_name"}}" style="padding:8px 12px; border:1px solid var(--border); border-radius:8px; background:var(--bg); color:var(--text);">
|
||||||
<input id="edit-cat-budget" type="number" placeholder="Budget (cents)" min="0" style="padding:8px 12px; border:1px solid var(--border); border-radius:8px; background:var(--bg); color:var(--text);">
|
<input id="edit-cat-budget" type="number" placeholder="{{$d.T.Get "settings.categories.modal_edit.placeholder_budget"}}" min="0" style="padding:8px 12px; border:1px solid var(--border); border-radius:8px; background:var(--bg); color:var(--text);">
|
||||||
<input id="edit-cat-color" type="color" style="width:100%; height:38px; padding:4px; border:1px solid var(--border); border-radius:8px; cursor:pointer;">
|
<input id="edit-cat-color" type="color" style="width:100%; height:38px; padding:4px; border:1px solid var(--border); border-radius:8px; cursor:pointer;">
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex; gap:8px; justify-content:flex-end; margin-top:16px;">
|
<div style="display:flex; gap:8px; justify-content:flex-end; margin-top:16px;">
|
||||||
<button onclick="closeEditCat()" class="btn" style="background:var(--bg); border:1px solid var(--border); color:var(--text);">Cancel</button>
|
<button onclick="closeEditCat()" class="btn" style="background:var(--bg); border:1px solid var(--border); color:var(--text);">{{$d.T.Get "settings.categories.modal_edit.btn_cancel"}}</button>
|
||||||
<button onclick="saveEditCat()" class="btn btn-primary">Save</button>
|
<button onclick="saveEditCat()" class="btn btn-primary">{{$d.T.Get "settings.categories.modal_edit.btn_save"}}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
const CONFIRM_DELETE_ACCOUNT = {{$d.T.Get "settings.accounts.confirm_delete" | printf "%q"}};
|
||||||
|
const CONFIRM_DELETE_CAT = {{$d.T.Get "settings.categories.confirm_delete" | printf "%q"}};
|
||||||
let editingCatID = null;
|
let editingCatID = null;
|
||||||
|
|
||||||
function deleteAccount(id) {
|
function deleteAccount(id) {
|
||||||
if (!confirm('Delete this account?')) return;
|
if (!confirm(CONFIRM_DELETE_ACCOUNT)) return;
|
||||||
fetch('/accounts/' + id, { method: 'DELETE' })
|
fetch('/accounts/' + id, { method: 'DELETE' })
|
||||||
.then(r => { if (r.ok) location.reload(); });
|
.then(r => { if (r.ok) location.reload(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteCat(id) {
|
function deleteCat(id) {
|
||||||
if (!confirm('Delete this category?')) return;
|
if (!confirm(CONFIRM_DELETE_CAT)) return;
|
||||||
fetch('/categories/' + id, { method: 'DELETE' })
|
fetch('/categories/' + id, { method: 'DELETE' })
|
||||||
.then(r => { if (r.ok) location.reload(); });
|
.then(r => { if (r.ok) location.reload(); });
|
||||||
}
|
}
|
||||||
@ -176,7 +178,6 @@ function saveEditCat() {
|
|||||||
.then(r => { if (r.ok) { closeEditCat(); location.reload(); } });
|
.then(r => { if (r.ok) { closeEditCat(); location.reload(); } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// After adding account/category, redirect back to same tab
|
|
||||||
document.getElementById('add-account-form')?.addEventListener('submit', function(e) {
|
document.getElementById('add-account-form')?.addEventListener('submit', function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
fetch(this.action, { method: 'POST', body: new FormData(this) })
|
fetch(this.action, { method: 'POST', body: new FormData(this) })
|
||||||
|
|||||||
@ -1,27 +1,27 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
{{$d := .}}
|
{{$d := .}}
|
||||||
<h1 style="margin-bottom:24px;">Sharing</h1>
|
<h1 style="margin-bottom:24px;">{{$d.T.Get "sharing.title"}}</h1>
|
||||||
|
|
||||||
<div class="card animate-on-scroll">
|
<div class="card animate-on-scroll">
|
||||||
<h2 style="margin-bottom:16px;">Grant Read Access</h2>
|
<h2 style="margin-bottom:16px;">{{$d.T.Get "sharing.grant_title"}}</h2>
|
||||||
<form method="POST" class="flex flex-wrap" style="gap:10px; align-items:flex-end; position:relative;">
|
<form method="POST" class="flex flex-wrap" style="gap:10px; align-items:flex-end; position:relative;">
|
||||||
<div class="form-group" style="margin-bottom:0; flex:1; min-width:220px; position:relative;">
|
<div class="form-group" style="margin-bottom:0; flex:1; min-width:220px; position:relative;">
|
||||||
<label>User Email</label>
|
<label>{{$d.T.Get "sharing.label_user_email"}}</label>
|
||||||
<input type="text" name="viewer_id" id="viewerSearch"
|
<input type="text" name="viewer_id" id="viewerSearch"
|
||||||
placeholder="Search by email…" autocomplete="off" required>
|
placeholder="{{$d.T.Get "sharing.placeholder_email"}}" autocomplete="off" required>
|
||||||
<div id="searchResults"></div>
|
<div id="searchResults"></div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary">Grant Access</button>
|
<button type="submit" class="btn btn-primary">{{$d.T.Get "sharing.btn_grant"}}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid-2">
|
<div class="grid-2">
|
||||||
<div class="card animate-on-scroll">
|
<div class="card animate-on-scroll">
|
||||||
<h2 style="margin-bottom:14px;">People with access to my finances</h2>
|
<h2 style="margin-bottom:14px;">{{$d.T.Get "sharing.my_finances_title"}}</h2>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>User</th><th>Since</th><th></th></tr>
|
<tr><th>{{$d.T.Get "sharing.col_user"}}</th><th>{{$d.T.Get "sharing.col_since"}}</th><th></th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range $d.Grants}}
|
{{range $d.Grants}}
|
||||||
@ -29,11 +29,11 @@
|
|||||||
<td style="font-size:13px;">{{.ViewerID}}</td>
|
<td style="font-size:13px;">{{.ViewerID}}</td>
|
||||||
<td class="text-muted">{{.CreatedAt.Format "02 Jan 2006"}}</td>
|
<td class="text-muted">{{.CreatedAt.Format "02 Jan 2006"}}</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<button class="btn btn-danger btn-sm" onclick="revoke('{{.ViewerID}}')">Revoke</button>
|
<button class="btn btn-danger btn-sm" onclick="revoke('{{.ViewerID}}')">{{$d.T.Get "sharing.btn_revoke"}}</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr><td colspan="3" class="text-center text-muted" style="padding:28px;">No access grants yet.</td></tr>
|
<tr><td colspan="3" class="text-center text-muted" style="padding:28px;">{{$d.T.Get "sharing.empty_my_grants"}}</td></tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -41,11 +41,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card animate-on-scroll">
|
<div class="card animate-on-scroll">
|
||||||
<h2 style="margin-bottom:14px;">Access granted to me</h2>
|
<h2 style="margin-bottom:14px;">{{$d.T.Get "sharing.access_to_me_title"}}</h2>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Owner</th><th>Since</th></tr>
|
<tr><th>{{$d.T.Get "sharing.col_owner"}}</th><th>{{$d.T.Get "sharing.col_since"}}</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range $d.Granted}}
|
{{range $d.Granted}}
|
||||||
@ -54,7 +54,7 @@
|
|||||||
<td class="text-muted">{{.CreatedAt.Format "02 Jan 2006"}}</td>
|
<td class="text-muted">{{.CreatedAt.Format "02 Jan 2006"}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr><td colspan="2" class="text-center text-muted" style="padding:28px;">No one has shared with you yet.</td></tr>
|
<tr><td colspan="2" class="text-center text-muted" style="padding:28px;">{{$d.T.Get "sharing.empty_access_to_me"}}</td></tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -87,6 +87,7 @@
|
|||||||
#searchResults div:hover { background: var(--bg3); }
|
#searchResults div:hover { background: var(--bg3); }
|
||||||
</style>
|
</style>
|
||||||
<script>
|
<script>
|
||||||
|
const CONFIRM_REVOKE = {{$d.T.Get "sharing.confirm_revoke" | printf "%q"}};
|
||||||
let searchTimer;
|
let searchTimer;
|
||||||
const input = document.getElementById('viewerSearch');
|
const input = document.getElementById('viewerSearch');
|
||||||
const results = document.getElementById('searchResults');
|
const results = document.getElementById('searchResults');
|
||||||
@ -114,7 +115,7 @@ function selectUser(id, email) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function revoke(viewerId) {
|
function revoke(viewerId) {
|
||||||
if (!confirm('Revoke access for this user?')) return;
|
if (!confirm(CONFIRM_REVOKE)) return;
|
||||||
fetch('/sharing/' + encodeURIComponent(viewerId), {method: 'DELETE'})
|
fetch('/sharing/' + encodeURIComponent(viewerId), {method: 'DELETE'})
|
||||||
.then(() => location.reload());
|
.then(() => location.reload());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,26 +2,26 @@
|
|||||||
{{$d := .}}
|
{{$d := .}}
|
||||||
|
|
||||||
<div style="display:flex; align-items:baseline; justify-content:space-between; margin-bottom:24px; flex-wrap:wrap; gap:8px;">
|
<div style="display:flex; align-items:baseline; justify-content:space-between; margin-bottom:24px; flex-wrap:wrap; gap:8px;">
|
||||||
<h1>What If…</h1>
|
<h1>{{$d.T.Get "simulator.title"}}</h1>
|
||||||
<span class="text-muted">adjust sliders to see the ripple effect</span>
|
<span class="text-muted">{{$d.T.Get "simulator.subtitle"}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Live output cards -->
|
<!-- Live output cards -->
|
||||||
<div class="grid" style="margin-bottom:16px;">
|
<div class="grid" style="margin-bottom:16px;">
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Disposable income</h2>
|
<h2>{{$d.T.Get "simulator.cards.disposable_income"}}</h2>
|
||||||
<div id="out-disposable" class="value" style="color:var(--text);">€0.00</div>
|
<div id="out-disposable" class="value" style="color:var(--text);">€0.00</div>
|
||||||
<p style="font-size:12px; color:var(--text3); margin-top:6px;">after fixed costs & goals</p>
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "simulator.cards.disposable_sub"}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Monthly savings</h2>
|
<h2>{{$d.T.Get "simulator.cards.monthly_savings"}}</h2>
|
||||||
<div id="out-savings" class="value positive">€0.00</div>
|
<div id="out-savings" class="value positive">€0.00</div>
|
||||||
<p style="font-size:12px; color:var(--text3); margin-top:6px;">income − all committed spend</p>
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "simulator.cards.monthly_savings_sub"}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Savings rate</h2>
|
<h2>{{$d.T.Get "simulator.cards.savings_rate"}}</h2>
|
||||||
<div id="out-rate" class="value positive">0%</div>
|
<div id="out-rate" class="value positive">0%</div>
|
||||||
<p style="font-size:12px; color:var(--text3); margin-top:6px;">savings ÷ income</p>
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "simulator.cards.savings_rate_sub"}}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -31,16 +31,15 @@
|
|||||||
<!-- Sliders -->
|
<!-- Sliders -->
|
||||||
<div class="card animate-on-scroll">
|
<div class="card animate-on-scroll">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
||||||
<h2>Adjustments</h2>
|
<h2>{{$d.T.Get "simulator.controls.section_title"}}</h2>
|
||||||
<button onclick="resetAll()" class="btn btn-outline btn-sm">Reset</button>
|
<button onclick="resetAll()" class="btn btn-outline btn-sm">{{$d.T.Get "simulator.controls.btn_reset"}}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:flex; flex-direction:column; gap:24px;">
|
<div style="display:flex; flex-direction:column; gap:24px;">
|
||||||
|
|
||||||
<!-- Income change -->
|
|
||||||
<div>
|
<div>
|
||||||
<div style="display:flex; justify-content:space-between; margin-bottom:8px;">
|
<div style="display:flex; justify-content:space-between; margin-bottom:8px;">
|
||||||
<label style="font-size:13px; color:var(--text2); font-weight:500;">Income change</label>
|
<label style="font-size:13px; color:var(--text2); font-weight:500;">{{$d.T.Get "simulator.controls.label_income_change"}}</label>
|
||||||
<span id="lbl-income" style="font-size:13px; font-weight:600; color:var(--accent);">+0%</span>
|
<span id="lbl-income" style="font-size:13px; font-weight:600; color:var(--accent);">+0%</span>
|
||||||
</div>
|
</div>
|
||||||
<input type="range" id="sl-income" min="-50" max="100" value="0" step="1"
|
<input type="range" id="sl-income" min="-50" max="100" value="0" step="1"
|
||||||
@ -50,10 +49,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- One-off expense -->
|
|
||||||
<div>
|
<div>
|
||||||
<div style="display:flex; justify-content:space-between; margin-bottom:8px;">
|
<div style="display:flex; justify-content:space-between; margin-bottom:8px;">
|
||||||
<label style="font-size:13px; color:var(--text2); font-weight:500;">One-off expense</label>
|
<label style="font-size:13px; color:var(--text2); font-weight:500;">{{$d.T.Get "simulator.controls.label_one_off"}}</label>
|
||||||
<span id="lbl-oneoff" style="font-size:13px; font-weight:600; color:var(--red);">€0</span>
|
<span id="lbl-oneoff" style="font-size:13px; font-weight:600; color:var(--red);">€0</span>
|
||||||
</div>
|
</div>
|
||||||
<input type="range" id="sl-oneoff" min="0" max="10000" value="0" step="50"
|
<input type="range" id="sl-oneoff" min="0" max="10000" value="0" step="50"
|
||||||
@ -63,10 +61,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Fixed costs change -->
|
|
||||||
<div>
|
<div>
|
||||||
<div style="display:flex; justify-content:space-between; margin-bottom:8px;">
|
<div style="display:flex; justify-content:space-between; margin-bottom:8px;">
|
||||||
<label style="font-size:13px; color:var(--text2); font-weight:500;">Fixed costs change</label>
|
<label style="font-size:13px; color:var(--text2); font-weight:500;">{{$d.T.Get "simulator.controls.label_fixed_costs"}}</label>
|
||||||
<span id="lbl-fixed" style="font-size:13px; font-weight:600; color:var(--text2);">+€0/mo</span>
|
<span id="lbl-fixed" style="font-size:13px; font-weight:600; color:var(--text2);">+€0/mo</span>
|
||||||
</div>
|
</div>
|
||||||
<input type="range" id="sl-fixed" min="-500" max="1000" value="0" step="10"
|
<input type="range" id="sl-fixed" min="-500" max="1000" value="0" step="10"
|
||||||
@ -76,28 +73,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- New goal -->
|
|
||||||
<div style="border-top:1px solid var(--border); padding-top:20px;">
|
<div style="border-top:1px solid var(--border); padding-top:20px;">
|
||||||
<label style="font-size:13px; color:var(--text2); font-weight:500; display:block; margin-bottom:12px;">
|
<label style="font-size:13px; color:var(--text2); font-weight:500; display:block; margin-bottom:12px;">
|
||||||
Hypothetical new goal
|
{{$d.T.Get "simulator.controls.label_new_goal"}}
|
||||||
</label>
|
</label>
|
||||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-bottom:10px;">
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-bottom:10px;">
|
||||||
<div>
|
<div>
|
||||||
<label style="font-size:11px; color:var(--text3); display:block; margin-bottom:4px;">Amount (€)</label>
|
<label style="font-size:11px; color:var(--text3); display:block; margin-bottom:4px;">{{$d.T.Get "simulator.controls.label_goal_amount"}}</label>
|
||||||
<input type="number" id="ng-amount" min="0" step="100" placeholder="5 000"
|
<input type="number" id="ng-amount" min="0" step="100" placeholder="{{$d.T.Get "simulator.controls.placeholder_goal_amount"}}"
|
||||||
oninput="recalc()"
|
oninput="recalc()"
|
||||||
style="width:100%; padding:8px 10px; background:var(--bg3); border:1px solid var(--border); border-radius:8px; color:var(--text); font-size:13px;">
|
style="width:100%; padding:8px 10px; background:var(--bg3); border:1px solid var(--border); border-radius:8px; color:var(--text); font-size:13px;">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style="font-size:11px; color:var(--text3); display:block; margin-bottom:4px;">Months to save</label>
|
<label style="font-size:11px; color:var(--text3); display:block; margin-bottom:4px;">{{$d.T.Get "simulator.controls.label_goal_months"}}</label>
|
||||||
<input type="number" id="ng-months" min="1" max="120" step="1" placeholder="12"
|
<input type="number" id="ng-months" min="1" max="120" step="1" placeholder="{{$d.T.Get "simulator.controls.placeholder_goal_months"}}"
|
||||||
oninput="recalc()"
|
oninput="recalc()"
|
||||||
style="width:100%; padding:8px 10px; background:var(--bg3); border:1px solid var(--border); border-radius:8px; color:var(--text); font-size:13px;">
|
style="width:100%; padding:8px 10px; background:var(--bg3); border:1px solid var(--border); border-radius:8px; color:var(--text); font-size:13px;">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:12px; color:var(--text3);">
|
<div style="font-size:12px; color:var(--text3);">
|
||||||
Would need <span id="ng-monthly" style="font-weight:600; color:var(--accent);">€0/mo</span>
|
{{$d.T.Get "simulator.controls.goal_would_need"}} <span id="ng-monthly" style="font-weight:600; color:var(--accent);">{{$d.T.Get "simulator.controls.per_month"}}</span>
|
||||||
— leaving <span id="ng-after" style="font-weight:600;">€0/mo</span> disposable
|
{{$d.T.Get "simulator.controls.leaving"}} <span id="ng-after" style="font-weight:600;">{{$d.T.Get "simulator.controls.disposable_after"}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -106,7 +102,7 @@
|
|||||||
|
|
||||||
<!-- Goal timeline impact -->
|
<!-- Goal timeline impact -->
|
||||||
<div class="card animate-on-scroll">
|
<div class="card animate-on-scroll">
|
||||||
<h2 style="margin-bottom:16px;">Goal timeline impact</h2>
|
<h2 style="margin-bottom:16px;">{{$d.T.Get "simulator.goal_impact.section_title"}}</h2>
|
||||||
{{if $d.Goals}}
|
{{if $d.Goals}}
|
||||||
<div id="goal-impact-list" style="display:flex; flex-direction:column; gap:10px;">
|
<div id="goal-impact-list" style="display:flex; flex-direction:column; gap:10px;">
|
||||||
{{range $d.Goals}}
|
{{range $d.Goals}}
|
||||||
@ -114,7 +110,7 @@
|
|||||||
<div style="display:flex; justify-content:space-between; align-items:baseline; margin-bottom:4px;">
|
<div style="display:flex; justify-content:space-between; align-items:baseline; margin-bottom:4px;">
|
||||||
<span style="font-size:13px; font-weight:500; color:var(--text);">
|
<span style="font-size:13px; font-weight:500; color:var(--text);">
|
||||||
{{.Name}}
|
{{.Name}}
|
||||||
{{if .Committed}}<span style="font-size:10px; color:var(--green); margin-left:5px;">committed</span>{{end}}
|
{{if .Committed}}<span style="font-size:10px; color:var(--green); margin-left:5px;">{{$.T.Get "simulator.goal_impact.committed"}}</span>{{end}}
|
||||||
</span>
|
</span>
|
||||||
<span class="goal-months" style="font-size:13px; font-weight:600; color:var(--text2);">{{.MonthsLeft}}mo</span>
|
<span class="goal-months" style="font-size:13px; font-weight:600; color:var(--text2);">{{.MonthsLeft}}mo</span>
|
||||||
</div>
|
</div>
|
||||||
@ -127,7 +123,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="empty-state" style="padding:24px;">
|
<div class="empty-state" style="padding:24px;">
|
||||||
<p>No goals yet. <a href="/goals" style="color:var(--accent);">Add some →</a></p>
|
<p>{{$d.T.Get "simulator.goal_impact.no_goals_msg"}} <a href="/goals" style="color:var(--accent);">{{$d.T.Get "simulator.goal_impact.goals_link"}}</a></p>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
@ -138,15 +134,14 @@
|
|||||||
{{if $d.SavingsHistory}}
|
{{if $d.SavingsHistory}}
|
||||||
<div class="card animate-on-scroll">
|
<div class="card animate-on-scroll">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
||||||
<h2>Savings rate history</h2>
|
<h2>{{$d.T.Get "simulator.history_chart.section_title"}}</h2>
|
||||||
<span style="font-size:11px; color:var(--text3);">past months</span>
|
<span style="font-size:11px; color:var(--text3);">{{$d.T.Get "simulator.history_chart.subtitle"}}</span>
|
||||||
</div>
|
</div>
|
||||||
<canvas id="savings-chart" height="200"></canvas>
|
<canvas id="savings-chart" height="200"></canvas>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// ── State seeded from server ──────────────────────────────────────────────
|
|
||||||
const BASE = {
|
const BASE = {
|
||||||
income: {{$d.IncomeCents}},
|
income: {{$d.IncomeCents}},
|
||||||
fixed: {{$d.FixedCents}},
|
fixed: {{$d.FixedCents}},
|
||||||
@ -155,6 +150,13 @@ const BASE = {
|
|||||||
avgSavings: {{$d.AvgSavingsCents}},
|
avgSavings: {{$d.AvgSavingsCents}},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MSG = {
|
||||||
|
notAchievable: {{$d.T.Get "simulator.goal_impact.feasibility_not_achievable" | printf "%q"}},
|
||||||
|
onTrack: {{$d.T.Get "simulator.goal_impact.feasibility_on_track" | printf "%q"}},
|
||||||
|
faster: {{$d.T.Get "simulator.goal_impact.feasibility_faster" | printf "%q"}},
|
||||||
|
over: {{$d.T.Get "simulator.goal_impact.feasibility_over" | printf "%q"}},
|
||||||
|
};
|
||||||
|
|
||||||
function fmt(cents) {
|
function fmt(cents) {
|
||||||
const abs = Math.abs(cents);
|
const abs = Math.abs(cents);
|
||||||
const sign = cents < 0 ? '−' : '';
|
const sign = cents < 0 ? '−' : '';
|
||||||
@ -163,7 +165,7 @@ function fmt(cents) {
|
|||||||
|
|
||||||
function recalc() {
|
function recalc() {
|
||||||
const incomePct = parseFloat(document.getElementById('sl-income').value) / 100;
|
const incomePct = parseFloat(document.getElementById('sl-income').value) / 100;
|
||||||
const oneoff = parseFloat(document.getElementById('sl-oneoff').value) * 100; // convert € to cents
|
const oneoff = parseFloat(document.getElementById('sl-oneoff').value) * 100;
|
||||||
const fixedDelta = parseFloat(document.getElementById('sl-fixed').value) * 100;
|
const fixedDelta = parseFloat(document.getElementById('sl-fixed').value) * 100;
|
||||||
|
|
||||||
const newIncome = Math.round(BASE.income * (1 + incomePct));
|
const newIncome = Math.round(BASE.income * (1 + incomePct));
|
||||||
@ -172,19 +174,16 @@ function recalc() {
|
|||||||
const newSavings = newIncome - newFixed - BASE.goals;
|
const newSavings = newIncome - newFixed - BASE.goals;
|
||||||
const newRate = newIncome > 0 ? Math.round(newSavings / newIncome * 100) : 0;
|
const newRate = newIncome > 0 ? Math.round(newSavings / newIncome * 100) : 0;
|
||||||
|
|
||||||
// labels
|
|
||||||
document.getElementById('lbl-income').textContent = (incomePct >= 0 ? '+' : '') + Math.round(incomePct * 100) + '%';
|
document.getElementById('lbl-income').textContent = (incomePct >= 0 ? '+' : '') + Math.round(incomePct * 100) + '%';
|
||||||
document.getElementById('lbl-income').style.color = incomePct >= 0 ? 'var(--green)' : 'var(--red)';
|
document.getElementById('lbl-income').style.color = incomePct >= 0 ? 'var(--green)' : 'var(--red)';
|
||||||
document.getElementById('lbl-oneoff').textContent = '€' + (oneoff/100).toLocaleString('pt-PT');
|
document.getElementById('lbl-oneoff').textContent = '€' + (oneoff/100).toLocaleString('pt-PT');
|
||||||
document.getElementById('lbl-fixed').textContent = (fixedDelta >= 0 ? '+' : '−') + '€' + (Math.abs(fixedDelta)/100).toLocaleString('pt-PT') + '/mo';
|
document.getElementById('lbl-fixed').textContent = (fixedDelta >= 0 ? '+' : '−') + '€' + (Math.abs(fixedDelta)/100).toLocaleString('pt-PT') + '/mo';
|
||||||
|
|
||||||
// output cards
|
|
||||||
setCard('out-disposable', newDisp);
|
setCard('out-disposable', newDisp);
|
||||||
setCard('out-savings', newSavings);
|
setCard('out-savings', newSavings);
|
||||||
document.getElementById('out-rate').textContent = newRate + '%';
|
document.getElementById('out-rate').textContent = newRate + '%';
|
||||||
document.getElementById('out-rate').className = 'value ' + (newRate >= 0 ? 'positive' : 'negative');
|
document.getElementById('out-rate').className = 'value ' + (newRate >= 0 ? 'positive' : 'negative');
|
||||||
|
|
||||||
// new goal impact
|
|
||||||
const ngAmt = parseFloat(document.getElementById('ng-amount').value || '0') * 100;
|
const ngAmt = parseFloat(document.getElementById('ng-amount').value || '0') * 100;
|
||||||
const ngMonths = parseFloat(document.getElementById('ng-months').value || '0');
|
const ngMonths = parseFloat(document.getElementById('ng-months').value || '0');
|
||||||
const ngMonthly = ngMonths > 0 ? Math.round(ngAmt / ngMonths) : 0;
|
const ngMonthly = ngMonths > 0 ? Math.round(ngAmt / ngMonths) : 0;
|
||||||
@ -194,35 +193,31 @@ function recalc() {
|
|||||||
ngAfterEl.textContent = fmt(ngAfter) + '/mo';
|
ngAfterEl.textContent = fmt(ngAfter) + '/mo';
|
||||||
ngAfterEl.style.color = ngAfter >= 0 ? 'var(--green)' : 'var(--red)';
|
ngAfterEl.style.color = ngAfter >= 0 ? 'var(--green)' : 'var(--red)';
|
||||||
|
|
||||||
// goal rows
|
|
||||||
document.querySelectorAll('.goal-row').forEach(row => {
|
document.querySelectorAll('.goal-row').forEach(row => {
|
||||||
const monthlyCents = parseInt(row.dataset.monthly);
|
const monthlyCents = parseInt(row.dataset.monthly);
|
||||||
const originalMonths = parseInt(row.dataset.months);
|
const originalMonths = parseInt(row.dataset.months);
|
||||||
const committed = row.dataset.committed === 'true';
|
const target = monthlyCents * originalMonths;
|
||||||
|
|
||||||
// at the new savings rate, how many months to hit the same target?
|
|
||||||
const target = monthlyCents * originalMonths;
|
|
||||||
const effectiveSavings = newSavings > 0 ? newSavings : 0;
|
const effectiveSavings = newSavings > 0 ? newSavings : 0;
|
||||||
const newMonths = effectiveSavings > 0 ? Math.ceil(target / effectiveSavings) : 9999;
|
const newMonths = effectiveSavings > 0 ? Math.ceil(target / effectiveSavings) : 9999;
|
||||||
const feasible = newMonths <= originalMonths;
|
const feasible = newMonths <= originalMonths;
|
||||||
|
|
||||||
row.querySelector('.goal-months').textContent = newMonths > 999 ? '—' : newMonths + 'mo';
|
row.querySelector('.goal-months').textContent = newMonths > 999 ? '—' : newMonths + 'mo';
|
||||||
row.querySelector('.goal-months').style.color = feasible ? 'var(--green)' : 'var(--red)';
|
row.querySelector('.goal-months').style.color = feasible ? 'var(--green)' : 'var(--red)';
|
||||||
|
|
||||||
const pct = Math.min(100, Math.round(originalMonths / Math.max(newMonths, 1) * 100));
|
const pct = Math.min(100, Math.round(originalMonths / Math.max(newMonths, 1) * 100));
|
||||||
row.querySelector('.goal-bar').style.width = pct + '%';
|
row.querySelector('.goal-bar').style.width = pct + '%';
|
||||||
row.querySelector('.goal-bar').style.background = feasible ? 'var(--green)' : 'var(--red)';
|
row.querySelector('.goal-bar').style.background = feasible ? 'var(--green)' : 'var(--red)';
|
||||||
|
|
||||||
const diff = newMonths - originalMonths;
|
const diff = newMonths - originalMonths;
|
||||||
const feasEl = row.querySelector('.goal-feasibility');
|
const feasEl = row.querySelector('.goal-feasibility');
|
||||||
if (newMonths > 999) {
|
if (newMonths > 999) {
|
||||||
feasEl.textContent = 'Not achievable at this savings level';
|
feasEl.textContent = MSG.notAchievable;
|
||||||
feasEl.style.color = 'var(--red)';
|
feasEl.style.color = 'var(--red)';
|
||||||
} else if (feasible) {
|
} else if (feasible) {
|
||||||
feasEl.textContent = diff < 0 ? Math.abs(diff) + ' months faster ✓' : 'On track ✓';
|
feasEl.textContent = diff < 0 ? Math.abs(diff) + ' ' + MSG.faster : MSG.onTrack;
|
||||||
feasEl.style.color = 'var(--green)';
|
feasEl.style.color = 'var(--green)';
|
||||||
} else {
|
} else {
|
||||||
feasEl.textContent = diff + ' months over deadline';
|
feasEl.textContent = diff + ' ' + MSG.over;
|
||||||
feasEl.style.color = 'var(--red)';
|
feasEl.style.color = 'var(--red)';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -243,11 +238,9 @@ function resetAll() {
|
|||||||
recalc();
|
recalc();
|
||||||
}
|
}
|
||||||
|
|
||||||
// run on load
|
|
||||||
recalc();
|
recalc();
|
||||||
|
|
||||||
{{if $d.SavingsHistory}}
|
{{if $d.SavingsHistory}}
|
||||||
// ── Savings rate chart ────────────────────────────────────────────────────
|
|
||||||
(function() {
|
(function() {
|
||||||
const labels = [{{range $d.SavingsHistory}}"{{.Month}}",{{end}}];
|
const labels = [{{range $d.SavingsHistory}}"{{.Month}}",{{end}}];
|
||||||
const rates = [{{range $d.SavingsHistory}}{{.RatePct}},{{end}}];
|
const rates = [{{range $d.SavingsHistory}}{{.RatePct}},{{end}}];
|
||||||
|
|||||||
@ -21,12 +21,12 @@ tr:last-child td { border-bottom:none; }
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; margin-bottom:8px;">
|
<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; margin-bottom:8px;">
|
||||||
<h1 style="margin:0;">Tax Summary</h1>
|
<h1 style="margin:0;">{{$d.T.Get "tax.title"}}</h1>
|
||||||
<a href="/tax/export.csv?year={{$d.Year}}" class="export-btn">Export CSV</a>
|
<a href="/tax/export.csv?year={{$d.Year}}" class="export-btn">{{$d.T.Get "tax.btn_export"}}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="year-form" method="get" action="/tax">
|
<form class="year-form" method="get" action="/tax">
|
||||||
<label for="year-sel" style="font-size:0.85rem; color:var(--muted);">Tax year:</label>
|
<label for="year-sel" style="font-size:0.85rem; color:var(--muted);">{{$d.T.Get "tax.year_label"}}</label>
|
||||||
<select id="year-sel" name="year" onchange="this.form.submit()">
|
<select id="year-sel" name="year" onchange="this.form.submit()">
|
||||||
{{range $d.AvailableYears}}
|
{{range $d.AvailableYears}}
|
||||||
<option value="{{.}}" {{if eq . $d.Year}}selected{{end}}>{{.}}</option>
|
<option value="{{.}}" {{if eq . $d.Year}}selected{{end}}>{{.}}</option>
|
||||||
@ -36,48 +36,48 @@ tr:last-child td { border-bottom:none; }
|
|||||||
|
|
||||||
<div class="tax-hero">
|
<div class="tax-hero">
|
||||||
<div class="tax-card">
|
<div class="tax-card">
|
||||||
<h3>Gross Income</h3>
|
<h3>{{$d.T.Get "tax.gross_income"}}</h3>
|
||||||
<div class="val gain">€{{cents $d.GrossIncomeCents}}</div>
|
<div class="val gain">€{{cents $d.GrossIncomeCents}}</div>
|
||||||
<div style="font-size:0.78rem; color:var(--muted); margin-top:4px;">from Income transactions</div>
|
<div style="font-size:0.78rem; color:var(--muted); margin-top:4px;">{{$d.T.Get "tax.gross_income_sub"}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tax-card">
|
<div class="tax-card">
|
||||||
<h3>Total Expenses</h3>
|
<h3>{{$d.T.Get "tax.total_expenses"}}</h3>
|
||||||
<div class="val neutral">€{{cents $d.TotalDeductCents}}</div>
|
<div class="val neutral">€{{cents $d.TotalDeductCents}}</div>
|
||||||
<div style="font-size:0.78rem; color:var(--muted); margin-top:4px;">across all categories</div>
|
<div style="font-size:0.78rem; color:var(--muted); margin-top:4px;">{{$d.T.Get "tax.total_expenses_sub"}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tax-card">
|
<div class="tax-card">
|
||||||
<h3>Capital Gains</h3>
|
<h3>{{$d.T.Get "tax.capital_gains"}}</h3>
|
||||||
<div class="val gain">€{{cents $d.CapitalGainsCents}}</div>
|
<div class="val gain">€{{cents $d.CapitalGainsCents}}</div>
|
||||||
<div style="font-size:0.78rem; color:var(--muted); margin-top:4px;">realized this year</div>
|
<div style="font-size:0.78rem; color:var(--muted); margin-top:4px;">{{$d.T.Get "tax.capital_gains_sub"}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tax-card">
|
<div class="tax-card">
|
||||||
<h3>Capital Losses</h3>
|
<h3>{{$d.T.Get "tax.capital_losses"}}</h3>
|
||||||
<div class="val loss">€{{cents $d.CapitalLossesCents}}</div>
|
<div class="val loss">€{{cents $d.CapitalLossesCents}}</div>
|
||||||
<div style="font-size:0.78rem; color:var(--muted); margin-top:4px;">realized this year</div>
|
<div style="font-size:0.78rem; color:var(--muted); margin-top:4px;">{{$d.T.Get "tax.capital_losses_sub"}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tax-card">
|
<div class="tax-card">
|
||||||
<h3>Net Capital</h3>
|
<h3>{{$d.T.Get "tax.net_capital"}}</h3>
|
||||||
{{if ge $d.NetCapitalCents 0}}
|
{{if ge $d.NetCapitalCents 0}}
|
||||||
<div class="val gain">€{{cents $d.NetCapitalCents}}</div>
|
<div class="val gain">€{{cents $d.NetCapitalCents}}</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="val loss">-€{{cents (centsAbs $d.NetCapitalCents)}}</div>
|
<div class="val loss">-€{{cents (centsAbs $d.NetCapitalCents)}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
<div style="font-size:0.78rem; color:var(--muted); margin-top:4px;">gains − losses</div>
|
<div style="font-size:0.78rem; color:var(--muted); margin-top:4px;">{{$d.T.Get "tax.net_capital_sub"}}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if $d.CapitalEntries}}
|
{{if $d.CapitalEntries}}
|
||||||
<div class="section-title">Realized Capital Events</div>
|
<div class="section-title">{{$d.T.Get "tax.capital_table.section_title"}}</div>
|
||||||
<div style="background:var(--card); border:1px solid var(--border); border-radius:12px; overflow:hidden; margin-bottom:24px;">
|
<div style="background:var(--card); border:1px solid var(--border); border-radius:12px; overflow:hidden; margin-bottom:24px;">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>{{$d.T.Get "tax.capital_table.col_name"}}</th>
|
||||||
<th>ISIN</th>
|
<th>{{$d.T.Get "tax.capital_table.col_isin"}}</th>
|
||||||
<th style="text-align:right">Cost Basis</th>
|
<th style="text-align:right">{{$d.T.Get "tax.capital_table.col_cost_basis"}}</th>
|
||||||
<th style="text-align:right">Proceeds</th>
|
<th style="text-align:right">{{$d.T.Get "tax.capital_table.col_proceeds"}}</th>
|
||||||
<th style="text-align:right">Gain/Loss</th>
|
<th style="text-align:right">{{$d.T.Get "tax.capital_table.col_gain_loss"}}</th>
|
||||||
<th style="text-align:right">%</th>
|
<th style="text-align:right">{{$d.T.Get "tax.capital_table.col_pct"}}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -100,14 +100,14 @@ tr:last-child td { border-bottom:none; }
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<div class="section-title">Expenses by Category</div>
|
<div class="section-title">{{$d.T.Get "tax.expenses_table.section_title"}}</div>
|
||||||
<div style="background:var(--card); border:1px solid var(--border); border-radius:12px; overflow:hidden;">
|
<div style="background:var(--card); border:1px solid var(--border); border-radius:12px; overflow:hidden;">
|
||||||
{{if $d.Deductibles}}
|
{{if $d.Deductibles}}
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Category</th>
|
<th>{{$d.T.Get "tax.expenses_table.col_category"}}</th>
|
||||||
<th style="text-align:right">Total Spent</th>
|
<th style="text-align:right">{{$d.T.Get "tax.expenses_table.col_total_spent"}}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -118,13 +118,13 @@ tr:last-child td { border-bottom:none; }
|
|||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
<tr style="font-weight:700; background:var(--bg);">
|
<tr style="font-weight:700; background:var(--bg);">
|
||||||
<td>Total</td>
|
<td>{{$d.T.Get "tax.expenses_table.row_total"}}</td>
|
||||||
<td style="text-align:right">€{{cents $d.TotalDeductCents}}</td>
|
<td style="text-align:right">€{{cents $d.TotalDeductCents}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div style="padding:32px; text-align:center; color:var(--muted);">No expense transactions for {{$d.Year}}</div>
|
<div style="padding:32px; text-align:center; color:var(--muted);">{{$d.T.Get "tax.expenses_table.empty_msg"}} {{$d.Year}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@ -3,41 +3,41 @@
|
|||||||
{{if eq $d.Notice "all_duplicates"}}
|
{{if eq $d.Notice "all_duplicates"}}
|
||||||
<div style="background:rgba(245,158,11,0.1); border:1px solid rgba(245,158,11,0.4); border-radius:10px; padding:12px 16px; margin-bottom:20px; display:flex; align-items:center; gap:10px;">
|
<div style="background:rgba(245,158,11,0.1); border:1px solid rgba(245,158,11,0.4); border-radius:10px; padding:12px 16px; margin-bottom:20px; display:flex; align-items:center; gap:10px;">
|
||||||
<span style="font-size:1.1rem;">⚠</span>
|
<span style="font-size:1.1rem;">⚠</span>
|
||||||
<span style="font-size:0.9rem;">Every row in that file was already imported — nothing was added.</span>
|
<span style="font-size:0.9rem;">{{$d.T.Get "transactions.warning_all_dupes"}}</span>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:24px; flex-wrap:wrap; gap:12px;">
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:24px; flex-wrap:wrap; gap:12px;">
|
||||||
<h1>Transactions</h1>
|
<h1>{{$d.T.Get "transactions.title"}}</h1>
|
||||||
<button class="btn btn-primary" onclick="openAddModal()">+ Add Transaction</button>
|
<button class="btn btn-primary" onclick="openAddModal()">{{$d.T.Get "transactions.btn_add"}}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card mb-16">
|
<div class="card mb-16">
|
||||||
<form method="GET" class="flex flex-wrap" style="gap:10px; align-items:flex-end;">
|
<form method="GET" class="flex flex-wrap" style="gap:10px; align-items:flex-end;">
|
||||||
<div class="form-group" style="margin-bottom:0; min-width:160px;">
|
<div class="form-group" style="margin-bottom:0; min-width:160px;">
|
||||||
<label>Category</label>
|
<label>{{$d.T.Get "transactions.filter.label_category"}}</label>
|
||||||
<select name="category">
|
<select name="category">
|
||||||
<option value="">All Categories</option>
|
<option value="">{{$d.T.Get "transactions.filter.option_all_cats"}}</option>
|
||||||
{{range $d.Categories}}
|
{{range $d.Categories}}
|
||||||
<option value="{{.Name}}" {{if eq $.Cat .Name}}selected{{end}}>{{.Name}}</option>
|
<option value="{{.Name}}" {{if eq $.Cat .Name}}selected{{end}}>{{.Name}}</option>
|
||||||
{{end}}
|
{{end}}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="margin-bottom:0; min-width:130px;">
|
<div class="form-group" style="margin-bottom:0; min-width:130px;">
|
||||||
<label>Period</label>
|
<label>{{$d.T.Get "transactions.filter.label_period"}}</label>
|
||||||
<select name="days">
|
<select name="days">
|
||||||
<option value="">All time</option>
|
<option value="">{{$d.T.Get "transactions.filter.option_all_time"}}</option>
|
||||||
<option value="30" {{if eq $.Days "30"}}selected{{end}}>30 days</option>
|
<option value="30" {{if eq $.Days "30"}}selected{{end}}>{{$d.T.Get "transactions.filter.option_30_days"}}</option>
|
||||||
<option value="90" {{if eq $.Days "90"}}selected{{end}}>90 days</option>
|
<option value="90" {{if eq $.Days "90"}}selected{{end}}>{{$d.T.Get "transactions.filter.option_90_days"}}</option>
|
||||||
<option value="365" {{if eq $.Days "365"}}selected{{end}}>1 year</option>
|
<option value="365" {{if eq $.Days "365"}}selected{{end}}>{{$d.T.Get "transactions.filter.option_1_year"}}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="margin-bottom:0; flex:1; min-width:200px;">
|
<div class="form-group" style="margin-bottom:0; flex:1; min-width:200px;">
|
||||||
<label>Search</label>
|
<label>{{$d.T.Get "transactions.filter.label_search"}}</label>
|
||||||
<input type="text" name="search" placeholder="Description…" value="{{.Search}}">
|
<input type="text" name="search" placeholder="{{$d.T.Get "transactions.filter.placeholder_search"}}" value="{{.Search}}">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary">Filter</button>
|
<button type="submit" class="btn btn-primary">{{$d.T.Get "transactions.filter.btn_filter"}}</button>
|
||||||
{{if or $.Cat $.Search $.Days}}
|
{{if or $.Cat $.Search $.Days}}
|
||||||
<a href="/transactions" class="btn btn-outline">Clear</a>
|
<a href="/transactions" class="btn btn-outline">{{$d.T.Get "transactions.filter.btn_clear"}}</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@ -50,11 +50,11 @@
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Date</th>
|
<th>{{$d.T.Get "transactions.table.col_date"}}</th>
|
||||||
<th>Description</th>
|
<th>{{$d.T.Get "transactions.table.col_description"}}</th>
|
||||||
<th>Account</th>
|
<th>{{$d.T.Get "transactions.table.col_account"}}</th>
|
||||||
<th>Category</th>
|
<th>{{$d.T.Get "transactions.table.col_category"}}</th>
|
||||||
<th class="text-right">Amount</th>
|
<th class="text-right">{{$d.T.Get "transactions.table.col_amount"}}</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -87,7 +87,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</select>
|
</select>
|
||||||
<button id="edit-btn-{{.ID}}" class="btn btn-outline btn-sm"
|
<button id="edit-btn-{{.ID}}" class="btn btn-outline btn-sm"
|
||||||
onclick="editCat('{{.ID}}')" title="Edit category"
|
onclick="editCat('{{.ID}}')" title="{{$d.T.Get "transactions.table.btn_edit_category"}}"
|
||||||
style="margin-left:4px; padding:3px 7px;">✎</button>
|
style="margin-left:4px; padding:3px 7px;">✎</button>
|
||||||
</td>
|
</td>
|
||||||
<td class="cents {{if lt .AmountCents 0}}negative{{else}}positive{{end}}"
|
<td class="cents {{if lt .AmountCents 0}}negative{{else}}positive{{end}}"
|
||||||
@ -95,15 +95,15 @@
|
|||||||
{{if lt .AmountCents 0}}−{{else}}+{{end}}€{{cents (centsAbs .AmountCents)}}
|
{{if lt .AmountCents 0}}−{{else}}+{{end}}€{{cents (centsAbs .AmountCents)}}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn-danger btn-sm" onclick="delTxn('{{.ID}}')" title="Delete">✕</button>
|
<button class="btn btn-danger btn-sm" onclick="delTxn('{{.ID}}')" title="{{$d.T.Get "transactions.table.btn_delete"}}">✕</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="6" class="text-center text-muted" style="padding:44px;">
|
<td colspan="6" class="text-center text-muted" style="padding:44px;">
|
||||||
No transactions found.
|
{{$d.T.Get "transactions.table.empty_msg"}}
|
||||||
<a href="/import" style="color:var(--accent);">Import some</a> or
|
<a href="/import" style="color:var(--accent);">{{$d.T.Get "transactions.table.empty_import_link"}}</a> or
|
||||||
<button class="btn btn-outline btn-sm" onclick="openAddModal()" style="margin-left:4px;">add manually</button>.
|
<button class="btn btn-outline btn-sm" onclick="openAddModal()" style="margin-left:4px;">{{$d.T.Get "transactions.table.empty_add_btn"}}</button>.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
@ -120,32 +120,32 @@
|
|||||||
border:1px solid var(--border2); box-shadow:var(--shadow-lg);
|
border:1px solid var(--border2); box-shadow:var(--shadow-lg);
|
||||||
animation:fadeUp 0.2s ease-out both;">
|
animation:fadeUp 0.2s ease-out both;">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
||||||
<span style="font-size:16px; font-weight:700;">Add Transaction</span>
|
<span style="font-size:16px; font-weight:700;">{{$d.T.Get "transactions.modal_add.title"}}</span>
|
||||||
<button class="btn btn-outline btn-sm" onclick="closeAddModal()" style="padding:3px 8px;">✕</button>
|
<button class="btn btn-outline btn-sm" onclick="closeAddModal()" style="padding:3px 8px;">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Date</label>
|
<label>{{$d.T.Get "transactions.modal_add.label_date"}}</label>
|
||||||
<input type="date" id="add-date" required>
|
<input type="date" id="add-date" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Description</label>
|
<label>{{$d.T.Get "transactions.modal_add.label_description"}}</label>
|
||||||
<input type="text" id="add-desc" placeholder="e.g. Coffee at Starbucks" required>
|
<input type="text" id="add-desc" placeholder="{{$d.T.Get "transactions.modal_add.placeholder_desc"}}" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Amount (€)</label>
|
<label>{{$d.T.Get "transactions.modal_add.label_amount"}}</label>
|
||||||
<div style="display:flex; gap:8px;">
|
<div style="display:flex; gap:8px;">
|
||||||
<select id="add-sign" style="width:110px; padding:9px 10px;
|
<select id="add-sign" style="width:110px; padding:9px 10px;
|
||||||
border:1px solid var(--border2); border-radius:var(--radius-sm);
|
border:1px solid var(--border2); border-radius:var(--radius-sm);
|
||||||
background:var(--bg2); color:var(--text);">
|
background:var(--bg2); color:var(--text);">
|
||||||
<option value="-1">− Expense</option>
|
<option value="-1">{{$d.T.Get "transactions.modal_add.option_expense"}}</option>
|
||||||
<option value="1">+ Income</option>
|
<option value="1">{{$d.T.Get "transactions.modal_add.option_income"}}</option>
|
||||||
</select>
|
</select>
|
||||||
<input type="number" id="add-amount" placeholder="0.00" step="0.01" min="0" style="flex:1;">
|
<input type="number" id="add-amount" placeholder="{{$d.T.Get "transactions.modal_add.placeholder_amount"}}" step="0.01" min="0" style="flex:1;">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Category</label>
|
<label>{{$d.T.Get "transactions.modal_add.label_category"}}</label>
|
||||||
<select id="add-cat">
|
<select id="add-cat">
|
||||||
{{range $d.Categories}}
|
{{range $d.Categories}}
|
||||||
<option value="{{.Name}}">{{.Name}}</option>
|
<option value="{{.Name}}">{{.Name}}</option>
|
||||||
@ -154,9 +154,9 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Account</label>
|
<label>{{$d.T.Get "transactions.modal_add.label_account"}}</label>
|
||||||
<select id="add-account">
|
<select id="add-account">
|
||||||
<option value="">— none —</option>
|
<option value="">{{$d.T.Get "transactions.modal_add.option_no_account"}}</option>
|
||||||
{{range $d.Accounts}}
|
{{range $d.Accounts}}
|
||||||
<option value="{{.ID}}">{{.Name}} ({{.Type}})</option>
|
<option value="{{.ID}}">{{.Name}} ({{.Type}})</option>
|
||||||
{{end}}
|
{{end}}
|
||||||
@ -166,8 +166,8 @@
|
|||||||
<div id="add-error" class="error" style="display:none;"></div>
|
<div id="add-error" class="error" style="display:none;"></div>
|
||||||
|
|
||||||
<div style="display:flex; gap:8px; justify-content:flex-end; margin-top:4px;">
|
<div style="display:flex; gap:8px; justify-content:flex-end; margin-top:4px;">
|
||||||
<button class="btn btn-outline" onclick="closeAddModal()">Cancel</button>
|
<button class="btn btn-outline" onclick="closeAddModal()">{{$d.T.Get "transactions.modal_add.btn_cancel"}}</button>
|
||||||
<button class="btn btn-primary" onclick="submitAdd()">Save Transaction</button>
|
<button class="btn btn-primary" onclick="submitAdd()">{{$d.T.Get "transactions.modal_add.btn_save"}}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -208,7 +208,7 @@ function saveCategory(id, cat) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function delTxn(id) {
|
function delTxn(id) {
|
||||||
if (!confirm('Delete this transaction?')) return;
|
if (!confirm('{{$d.T.Get "transactions.confirm.delete_msg"}}')) return;
|
||||||
fetch('/api/transactions/' + id, {method: 'DELETE'}).then(r => {
|
fetch('/api/transactions/' + id, {method: 'DELETE'}).then(r => {
|
||||||
if (r.ok) document.getElementById('row-' + id).remove();
|
if (r.ok) document.getElementById('row-' + id).remove();
|
||||||
});
|
});
|
||||||
@ -233,7 +233,7 @@ function submitAdd() {
|
|||||||
const errEl = document.getElementById('add-error');
|
const errEl = document.getElementById('add-error');
|
||||||
|
|
||||||
if (!date || !desc || isNaN(amt) || amt <= 0) {
|
if (!date || !desc || isNaN(amt) || amt <= 0) {
|
||||||
errEl.textContent = 'Please fill in date, description, and a positive amount.';
|
errEl.textContent = '{{$d.T.Get "transactions.modal_add.error_required"}}';
|
||||||
errEl.style.display = 'block';
|
errEl.style.display = 'block';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -244,7 +244,7 @@ function submitAdd() {
|
|||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({account_id: acct, date, description: desc, amount_cents: Math.round(amt * 100) * sign, category: cat})
|
body: JSON.stringify({account_id: acct, date, description: desc, amount_cents: Math.round(amt * 100) * sign, category: cat})
|
||||||
}).then(r => {
|
}).then(r => {
|
||||||
if (!r.ok) { errEl.textContent = 'Failed to save.'; errEl.style.display = 'block'; return; }
|
if (!r.ok) { errEl.textContent = '{{$d.T.Get "transactions.modal_add.error_save_failed"}}'; errEl.style.display = 'block'; return; }
|
||||||
closeAddModal();
|
closeAddModal();
|
||||||
location.reload();
|
location.reload();
|
||||||
});
|
});
|
||||||
|
|||||||
1
go.mod
1
go.mod
@ -14,6 +14,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
|||||||
2
go.sum
2
go.sum
@ -1,3 +1,5 @@
|
|||||||
|
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user