refactor: consolidate nav from 15 items to 7

- Merge Sharing + Household → /people (tab switcher: sharing | household)
- Merge Accounts + Categories → /settings (tab switcher: accounts | categories)
- Add Analysis dropdown in nav: Reports, Projections, Tax, Net Worth, What If
- Add Settings dropdown in nav: Accounts & Categories, Import CSV, Import Guide
- Legacy GET /sharing, /household, /accounts, /categories redirect 301 to new URLs
- Remove Import and Import Guide as standalone nav links
- New People handler consolidates all people-related mutations (_action field)
- New Settings handler renders both account and category lists in one page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gonçalo Rodrigues 2026-06-13 18:33:15 +01:00
parent 3dd7362964
commit 437fb5a2df
5 changed files with 642 additions and 20 deletions

View File

@ -125,6 +125,8 @@ var (
taxTmpl = parseTmpl("templates/base.html", "templates/tax.html") taxTmpl = parseTmpl("templates/base.html", "templates/tax.html")
householdTmpl = parseTmpl("templates/base.html", "templates/household.html") householdTmpl = parseTmpl("templates/base.html", "templates/household.html")
autoImportTmpl = parseTmpl("templates/base.html", "templates/auto_import.html") autoImportTmpl = parseTmpl("templates/base.html", "templates/auto_import.html")
peopleTmpl = parseTmpl("templates/base.html", "templates/people.html")
settingsTmpl = parseTmpl("templates/base.html", "templates/settings.html")
) )
type authInfo struct { type authInfo struct {
@ -2252,6 +2254,159 @@ func (h *Handler) AutoImport(w http.ResponseWriter, r *http.Request) {
}) })
} }
// ── People (Sharing + Household merged) ───────────────────────────────────────
func (h *Handler) People(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
a := getAuth(r)
tab := r.URL.Query().Get("tab")
if tab == "" {
tab = "sharing"
}
// Handle mutations — redirect back preserving tab
if r.Method == http.MethodPost {
_ = r.ParseForm()
switch r.FormValue("_action") {
case "share":
viewerID := r.FormValue("viewer_id")
if viewerID != "" && viewerID != a.UserID {
existing, _ := h.store.getPermissions(ctx, a.UserID)
already := false
for _, p := range existing {
if p.ViewerID == viewerID {
already = true
break
}
}
if !already {
_ = h.store.createPermission(ctx, &Permission{
ID: bson.NewObjectID().Hex(),
OwnerID: a.UserID,
ViewerID: viewerID,
CreatedAt: time.Now(),
})
}
}
http.Redirect(w, r, "/people?tab=sharing", http.StatusSeeOther)
return
case "household":
partnerEmail := strings.TrimSpace(r.FormValue("partner_email"))
if partnerEmail != "" {
_ = h.store.createHousehold(ctx, &Household{
ID: bson.NewObjectID().Hex(),
OwnerID: a.UserID,
PartnerID: partnerEmail,
CreatedAt: time.Now(),
})
}
http.Redirect(w, r, "/people?tab=household", http.StatusSeeOther)
return
}
}
if r.Method == http.MethodDelete {
switch r.URL.Query().Get("kind") {
case "share":
_ = h.store.deletePermission(ctx, a.UserID, r.PathValue("id"))
case "household":
_ = h.store.deleteHousehold(ctx, a.UserID)
}
w.WriteHeader(http.StatusNoContent)
return
}
data := &PeopleData{
UserID: a.UserID,
Email: a.Email,
Title: "People",
Route: "people",
Tab: tab,
}
// Sharing data
perms, _ := h.store.getPermissions(ctx, a.UserID)
granted, _ := h.store.getGrantedViewers(ctx, a.UserID)
viewerIDs := map[string]bool{}
for _, p := range perms {
viewerIDs[p.ViewerID] = true
}
for id := range viewerIDs {
data.Viewers = append(data.Viewers, SharingUser{ID: id, Email: id})
}
data.Grants = perms
data.Granted = granted
// Household data
now := time.Now()
hh, err := h.store.getHousehold(ctx, a.UserID)
if err == nil && hh != nil {
data.HasHousehold = true
data.IsOwner = hh.OwnerID == a.UserID
partnerID := hh.PartnerID
if hh.OwnerID != a.UserID {
partnerID = hh.OwnerID
}
data.PartnerID = partnerID
data.PartnerEmail = partnerID
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
nextMonth := monthStart.AddDate(0, 1, 0)
myTxns, _ := h.store.getTransactions(ctx, a.UserID, bson.M{"date": bson.M{"$gte": monthStart, "$lt": nextMonth}})
partnerTxns, _ := h.store.getTransactions(ctx, partnerID, bson.M{"date": bson.M{"$gte": monthStart, "$lt": nextMonth}})
for _, t := range myTxns {
if t.AmountCents > 0 {
data.MyIncomeCents += t.AmountCents
} else {
data.CombinedExpenseCents += -t.AmountCents
}
}
for _, t := range partnerTxns {
if t.AmountCents > 0 {
data.PartnerIncomeCents += t.AmountCents
} else {
data.CombinedExpenseCents += -t.AmountCents
}
}
data.CombinedIncomeCents = data.MyIncomeCents + data.PartnerIncomeCents
data.CombinedDisposable = data.CombinedIncomeCents - data.CombinedExpenseCents
myGoals, _ := h.store.getGoals(ctx, a.UserID)
partnerGoals, _ := h.store.getGoals(ctx, partnerID)
for _, g := range myGoals {
data.MyGoals = append(data.MyGoals, GoalPlan{Goal: g})
}
for _, g := range partnerGoals {
data.PartnerGoals = append(data.PartnerGoals, GoalPlan{Goal: g})
}
}
render(w, peopleTmpl, data)
}
// ── Settings (Accounts + Categories merged) ────────────────────────────────────
func (h *Handler) Settings(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
a := getAuth(r)
tab := r.URL.Query().Get("tab")
if tab == "" {
tab = "accounts"
}
accounts, _ := h.store.getAccounts(ctx, a.UserID)
categories, _ := h.store.getCategories(ctx, a.UserID)
render(w, settingsTmpl, &SettingsData{
UserID: a.UserID,
Email: a.Email,
Title: "Settings",
Route: "settings",
Tab: tab,
Accounts: accounts,
Categories: categories,
})
}
func (h *Handler) RegisterRoutes(mux *http.ServeMux) { func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /{$}", h.Dashboard) mux.HandleFunc("GET /{$}", h.Dashboard)
mux.HandleFunc("GET /transactions", h.Transactions) mux.HandleFunc("GET /transactions", h.Transactions)
@ -2259,10 +2414,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /import/preview", h.ImportPreview) mux.HandleFunc("POST /import/preview", h.ImportPreview)
mux.HandleFunc("POST /import/confirm", h.ImportConfirm) mux.HandleFunc("POST /import/confirm", h.ImportConfirm)
mux.HandleFunc("POST /import/securities", h.ImportSecurities) mux.HandleFunc("POST /import/securities", h.ImportSecurities)
mux.HandleFunc("GET /accounts", h.Accounts)
mux.HandleFunc("POST /accounts", h.Accounts) mux.HandleFunc("POST /accounts", h.Accounts)
mux.HandleFunc("DELETE /accounts/{id}", h.Accounts) mux.HandleFunc("DELETE /accounts/{id}", h.Accounts)
mux.HandleFunc("GET /categories", h.Categories)
mux.HandleFunc("POST /categories", h.Categories) mux.HandleFunc("POST /categories", h.Categories)
mux.HandleFunc("PUT /categories/{id}", h.Categories) mux.HandleFunc("PUT /categories/{id}", h.Categories)
mux.HandleFunc("DELETE /categories/{id}", h.Categories) mux.HandleFunc("DELETE /categories/{id}", h.Categories)
@ -2273,18 +2426,31 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /goals", h.Goals) mux.HandleFunc("POST /goals", h.Goals)
mux.HandleFunc("GET /networth", h.NetWorth) mux.HandleFunc("GET /networth", h.NetWorth)
mux.HandleFunc("GET /simulator", h.Simulator) mux.HandleFunc("GET /simulator", h.Simulator)
mux.HandleFunc("GET /sharing", h.Sharing) // legacy redirects so old bookmarks / links keep working
mux.HandleFunc("POST /sharing", h.Sharing) mux.HandleFunc("GET /sharing", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("DELETE /sharing/{viewer_id}", h.Sharing) http.Redirect(w, r, "/people?tab=sharing", http.StatusMovedPermanently)
})
mux.HandleFunc("GET /household", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/people?tab=household", http.StatusMovedPermanently)
})
mux.HandleFunc("GET /accounts", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/settings?tab=accounts", http.StatusMovedPermanently)
})
mux.HandleFunc("GET /categories", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/settings?tab=categories", http.StatusMovedPermanently)
})
// people page
mux.HandleFunc("GET /people", h.People)
mux.HandleFunc("POST /people", h.People)
mux.HandleFunc("DELETE /people/{id}", h.People)
// settings page
mux.HandleFunc("GET /settings", h.Settings)
mux.HandleFunc("GET /api/users/search", h.SearchUsers) mux.HandleFunc("GET /api/users/search", h.SearchUsers)
mux.HandleFunc("POST /api/transactions", h.CreateTransaction) mux.HandleFunc("POST /api/transactions", h.CreateTransaction)
mux.HandleFunc("PUT /api/transactions/{id}", h.UpdateTransaction) mux.HandleFunc("PUT /api/transactions/{id}", h.UpdateTransaction)
mux.HandleFunc("DELETE /api/transactions/{id}", h.DeleteTransaction) mux.HandleFunc("DELETE /api/transactions/{id}", h.DeleteTransaction)
mux.HandleFunc("GET /tax", h.Tax) mux.HandleFunc("GET /tax", h.Tax)
mux.HandleFunc("GET /tax/export.csv", h.TaxExport) mux.HandleFunc("GET /tax/export.csv", h.TaxExport)
mux.HandleFunc("GET /household", h.Household)
mux.HandleFunc("POST /household", h.Household)
mux.HandleFunc("DELETE /household", h.Household)
mux.HandleFunc("GET /auto-import", h.AutoImport) mux.HandleFunc("GET /auto-import", h.AutoImport)
} }

View File

@ -270,6 +270,44 @@ type Household struct {
CreatedAt time.Time `bson:"created_at" json:"created_at"` CreatedAt time.Time `bson:"created_at" json:"created_at"`
} }
// PeopleData combines Sharing and Household into a single page.
type PeopleData struct {
UserID string
Email string
Title string
Route string
Tab string // "sharing" | "household"
// sharing tab
Grants []Permission
Viewers []SharingUser
Granted []Permission
// household tab
HasHousehold bool
IsOwner bool
PartnerEmail string
PartnerID string
CombinedIncomeCents int64
CombinedExpenseCents int64
CombinedDisposable int64
MyIncomeCents int64
PartnerIncomeCents int64
MyGoals []GoalPlan
PartnerGoals []GoalPlan
}
// SettingsData combines Accounts and Categories into a single page.
type SettingsData struct {
UserID string
Email string
Title string
Route string
Tab string // "accounts" | "categories"
Accounts []Account
Categories []Category
}
type HouseholdData struct { type HouseholdData struct {
UserID string UserID string
Email string Email string

View File

@ -140,6 +140,48 @@
.nav a:not(.nav-brand):hover { color: var(--text); background: var(--surface2); } .nav a:not(.nav-brand):hover { color: var(--text); background: var(--surface2); }
.nav a.active { color: var(--accent2); background: var(--accent-glow); } .nav a.active { color: var(--accent2); background: var(--accent-glow); }
.nav-spacer { flex: 1; } .nav-spacer { flex: 1; }
/* ── Nav dropdown ─────────────────────────────────────────────────── */
.nav-group { position: relative; }
.nav-group-btn {
display: flex; align-items: center; gap: 4px;
color: var(--text2);
font-size: 13.5px; font-weight: 500;
padding: 6px 10px;
border-radius: var(--radius-sm);
border: none; background: none; cursor: pointer;
transition: all 0.18s ease; white-space: nowrap;
}
.nav-group-btn:hover { color: var(--text); background: var(--surface2); }
.nav-group-btn.active { color: var(--accent2); background: var(--accent-glow); }
.nav-group-btn svg { width:10px; height:10px; transition: transform 0.18s ease; }
.nav-group:hover .nav-group-btn svg { transform: rotate(180deg); }
.nav-dropdown {
display: none;
position: absolute; top: calc(100% + 6px); left: 0;
background: var(--surface);
border: 1px solid var(--border2);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
min-width: 160px;
z-index: 200;
padding: 6px;
flex-direction: column; gap: 2px;
}
.nav-group:hover .nav-dropdown { display: flex; }
.nav-dropdown a {
display: block;
padding: 7px 12px;
border-radius: var(--radius-sm);
font-size: 13px; font-weight: 500;
color: var(--text2); text-decoration: none;
white-space: nowrap;
transition: all 0.15s ease;
}
.nav-dropdown a:hover { color: var(--text); background: var(--surface2); }
.nav-dropdown a.active { color: var(--accent2); background: var(--accent-glow); }
.nav-dropdown hr { border: none; border-top: 1px solid var(--border); margin: 4px 0; }
.nav-email { .nav-email {
font-size: 12px; font-size: 12px;
color: var(--text3); color: var(--text3);
@ -393,19 +435,37 @@
</a> </a>
<a href="/" class="{{if eq .Route "dashboard"}}active{{end}}">Dashboard</a> <a href="/" class="{{if eq .Route "dashboard"}}active{{end}}">Dashboard</a>
<a href="/transactions" class="{{if eq .Route "transactions"}}active{{end}}">Transactions</a> <a href="/transactions" class="{{if eq .Route "transactions"}}active{{end}}">Transactions</a>
<a href="/import" class="{{if eq .Route "import"}}active{{end}}">Import</a>
<a href="/accounts" class="{{if eq .Route "accounts"}}active{{end}}">Accounts</a>
<a href="/categories" class="{{if eq .Route "categories"}}active{{end}}">Categories</a>
<a href="/reports" class="{{if eq .Route "reports"}}active{{end}}">Reports</a>
<a href="/projections" class="{{if eq .Route "projections"}}active{{end}}">Projections</a>
<a href="/portfolio" class="{{if eq .Route "portfolio"}}active{{end}}">Portfolio</a> <a href="/portfolio" class="{{if eq .Route "portfolio"}}active{{end}}">Portfolio</a>
<a href="/goals" class="{{if eq .Route "goals"}}active{{end}}">Goals</a> <a href="/goals" class="{{if eq .Route "goals"}}active{{end}}">Goals</a>
{{$analysisActive := or (eq .Route "reports") (eq .Route "projections") (eq .Route "networth") (eq .Route "simulator") (eq .Route "tax")}}
<div class="nav-group">
<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>
</button>
<div class="nav-dropdown">
<a href="/reports" class="{{if eq .Route "reports"}}active{{end}}">Reports</a>
<a href="/projections" class="{{if eq .Route "projections"}}active{{end}}">Projections</a>
<a href="/tax" class="{{if eq .Route "tax"}}active{{end}}">Tax</a>
<hr>
<a href="/networth" class="{{if eq .Route "networth"}}active{{end}}">Net Worth</a> <a href="/networth" class="{{if eq .Route "networth"}}active{{end}}">Net Worth</a>
<a href="/simulator" class="{{if eq .Route "simulator"}}active{{end}}">What If</a> <a href="/simulator" class="{{if eq .Route "simulator"}}active{{end}}">What If</a>
<a href="/tax" class="{{if eq .Route "tax"}}active{{end}}">Tax</a> </div>
<a href="/household" class="{{if eq .Route "household"}}active{{end}}">Household</a> </div>
<a href="/people" class="{{if eq .Route "people"}}active{{end}}">People</a>
{{$settingsActive := or (eq .Route "settings") (eq .Route "auto-import")}}
<div class="nav-group">
<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>
</button>
<div class="nav-dropdown">
<a href="/settings?tab=accounts" class="{{if eq .Route "settings"}}active{{end}}">Accounts & Categories</a>
<a href="/import" class="{{if eq .Route "import"}}active{{end}}">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}}">Import Guide</a>
<a href="/sharing" class="{{if eq .Route "sharing"}}active{{end}}">Sharing</a> </div>
</div>
<div class="nav-spacer"></div> <div class="nav-spacer"></div>
<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> <button class="theme-btn" id="theme-toggle" title="Toggle dark/light mode">🌙</button>

View File

@ -0,0 +1,167 @@
{{template "base" .}}
{{define "content"}}
{{$d := .}}
<style>
.tab-bar { display:flex; gap:4px; border-bottom:2px solid var(--border); margin-bottom:24px; }
.tab-bar a { padding:8px 18px; font-size:0.9rem; font-weight:600; color:var(--muted); text-decoration:none; border-bottom:2px solid transparent; margin-bottom:-2px; transition:all 0.15s; }
.tab-bar a.active { color:var(--accent); border-bottom-color:var(--accent); }
.tab-bar a:hover:not(.active) { color:var(--text); }
.people-card { background:var(--card); border:1px solid var(--border); border-radius:12px; padding:22px 24px; margin-bottom:16px; }
.people-card h3 { margin:0 0 14px; font-size:0.95rem; font-weight:700; }
.form-row { display:flex; gap:10px; }
.form-row input { flex:1; padding:8px 12px; border:1px solid var(--border); border-radius:8px; background:var(--bg); color:var(--text); font-size:0.9rem; }
.btn { padding:8px 18px; border:none; border-radius:8px; font-size:0.875rem; font-weight:600; cursor:pointer; }
.btn-primary { background:var(--accent); color:#fff; }
.btn-danger { background:#f44336; color:#fff; font-size:0.8rem; padding:5px 12px; }
.viewer-row { display:flex; align-items:center; justify-content:space-between; padding:10px 0; border-bottom:1px solid var(--border); font-size:0.875rem; }
.viewer-row:last-child { border-bottom:none; }
.empty-state { padding:32px; text-align:center; color:var(--muted); font-size:0.875rem; }
.stat-row { display:flex; gap:12px; flex-wrap:wrap; margin-bottom:16px; }
.stat { background:var(--bg); border:1px solid var(--border); border-radius:10px; padding:14px 18px; flex:1; min-width:130px; }
.stat label { display:block; font-size:0.75rem; color:var(--muted); text-transform:uppercase; letter-spacing:.04em; margin-bottom:4px; }
.stat .val { font-size:1.3rem; font-weight:700; }
.goals-grid { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
.goal-item { padding:8px 12px; border-bottom:1px solid var(--border); font-size:0.875rem; display:flex; justify-content:space-between; }
.goal-item:last-child { border-bottom:none; }
.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>
<h1 style="margin:0 0 20px;">People</h1>
<div class="tab-bar">
<a href="/people?tab=sharing" class="{{if eq $d.Tab "sharing"}}active{{end}}">Sharing</a>
<a href="/people?tab=household" class="{{if eq $d.Tab "household"}}active{{end}}">Household</a>
</div>
{{if eq $d.Tab "sharing"}}
<div class="people-card">
<h3>Grant read access</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>
<form method="post" action="/people?tab=sharing">
<input type="hidden" name="_action" value="share">
<div class="form-row">
<input type="text" name="viewer_id" placeholder="User ID or email" required>
<button type="submit" class="btn btn-primary">Add</button>
</div>
</form>
</div>
{{if $d.Viewers}}
<div class="people-card">
<h3>People with access to your data</h3>
{{range $d.Viewers}}
<div class="viewer-row">
<span>{{.Email}}</span>
<button class="btn btn-danger" onclick="revokeShare('{{.ID}}')">Revoke</button>
</div>
{{end}}
</div>
{{end}}
{{if $d.Granted}}
<div class="people-card">
<h3>Accounts you can view</h3>
{{range $d.Granted}}
<div class="viewer-row">
<span style="color:var(--muted);">{{.OwnerID}}</span>
<a href="/?as={{.OwnerID}}" style="font-size:0.8rem; color:var(--accent);">View →</a>
</div>
{{end}}
</div>
{{end}}
{{if and (not $d.Viewers) (not $d.Granted)}}
<div class="people-card"><div class="empty-state">No sharing configured yet.</div></div>
{{end}}
{{else}}{{/* household tab */}}
{{if not $d.HasHousehold}}
<div class="people-card" style="max-width:480px;">
<h3>Link a partner account</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>
<form method="post" action="/people?tab=household">
<input type="hidden" name="_action" value="household">
<div class="form-row">
<input type="email" name="partner_email" placeholder="Partner's email" required>
<button type="submit" class="btn btn-primary">Link</button>
</div>
</form>
</div>
{{else}}
<div class="people-card" style="display:flex; align-items:center; gap:14px;">
<div style="flex:1;">
<div style="font-size:0.78rem; color:var(--muted); margin-bottom:2px;">Linked partner</div>
<div style="font-weight:600;">{{$d.PartnerEmail}}</div>
</div>
<button class="btn btn-danger" onclick="unlinkHousehold()">Unlink</button>
</div>
<div class="stat-row">
<div class="stat">
<label>Combined Income</label>
<div class="val" style="color:#4caf50;">€{{cents $d.CombinedIncomeCents}}</div>
</div>
<div class="stat">
<label>My Income</label>
<div class="val">€{{cents $d.MyIncomeCents}}</div>
</div>
<div class="stat">
<label>Partner Income</label>
<div class="val">€{{cents $d.PartnerIncomeCents}}</div>
</div>
<div class="stat">
<label>Combined Expenses</label>
<div class="val" style="color:#f44336;">€{{cents $d.CombinedExpenseCents}}</div>
</div>
<div class="stat">
<label>Disposable</label>
<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)}}
</div>
</div>
</div>
{{if or $d.MyGoals $d.PartnerGoals}}
<div class="goals-grid">
<div class="people-card" style="margin-bottom:0;">
<h3>Your Goals</h3>
{{range $d.MyGoals}}
<div class="goal-item">
<span>{{.Name}}</span>
{{if .Committed}}<span class="badge-committed">committed</span>{{end}}
</div>
{{else}}<div style="color:var(--muted); font-size:0.83rem;">No goals</div>{{end}}
</div>
<div class="people-card" style="margin-bottom:0;">
<h3>Partner Goals</h3>
{{range $d.PartnerGoals}}
<div class="goal-item">
<span>{{.Name}}</span>
{{if .Committed}}<span class="badge-committed">committed</span>{{end}}
</div>
{{else}}<div style="color:var(--muted); font-size:0.83rem;">No goals</div>{{end}}
</div>
</div>
{{end}}
{{end}}{{/* HasHousehold */}}
{{end}}{{/* tab */}}
<script>
function revokeShare(id) {
if (!confirm('Revoke access for this user?')) return;
fetch('/people/' + id + '?kind=share', { method: 'DELETE' })
.then(r => { if (r.ok) location.reload(); });
}
function unlinkHousehold() {
if (!confirm('Unlink household? This only removes the link, not any data.')) return;
fetch('/people/_?kind=household', { method: 'DELETE' })
.then(r => { if (r.ok) location.href = '/people?tab=household'; });
}
</script>
{{end}}

View File

@ -0,0 +1,191 @@
{{template "base" .}}
{{define "content"}}
{{$d := .}}
<style>
.tab-bar { display:flex; gap:4px; border-bottom:2px solid var(--border); margin-bottom:24px; }
.tab-bar a { padding:8px 18px; font-size:0.9rem; font-weight:600; color:var(--muted); text-decoration:none; border-bottom:2px solid transparent; margin-bottom:-2px; transition:all 0.15s; }
.tab-bar a.active { color:var(--accent); border-bottom-color:var(--accent); }
.tab-bar a:hover:not(.active) { color:var(--text); }
.s-card { background:var(--card); border:1px solid var(--border); border-radius:12px; padding:22px 24px; margin-bottom:16px; }
.s-card h3 { margin:0 0 14px; font-size:0.95rem; font-weight:700; }
.form-row { display:flex; gap:10px; flex-wrap:wrap; }
.form-row input, .form-row select { flex:1; min-width:120px; padding:8px 12px; border:1px solid var(--border); border-radius:8px; background:var(--bg); color:var(--text); font-size:0.875rem; }
.btn { padding:8px 18px; border:none; border-radius:8px; font-size:0.875rem; font-weight:600; cursor:pointer; }
.btn-primary { background:var(--accent); color:#fff; }
.btn-danger { background:transparent; border:1px solid #f4433660; color:#f44336; font-size:0.8rem; padding:4px 10px; border-radius:6px; }
.btn-danger:hover { background:#f4433615; }
table { width:100%; border-collapse:collapse; font-size:0.875rem; }
th { text-align:left; padding:8px 12px; color:var(--muted); font-weight:600; border-bottom:2px solid var(--border); }
td { padding:10px 12px; border-bottom:1px solid var(--border); vertical-align:middle; }
tr:last-child td { border-bottom:none; }
.color-dot { display:inline-block; width:12px; height:12px; border-radius:50%; margin-right:6px; vertical-align:middle; }
.type-badge { display:inline-block; padding:2px 8px; border-radius:20px; font-size:0.72rem; font-weight:600; background:var(--bg); border:1px solid var(--border); color:var(--muted); }
.empty-state { padding:32px; text-align:center; color:var(--muted); font-size:0.875rem; }
</style>
<h1 style="margin:0 0 20px;">Settings</h1>
<div class="tab-bar">
<a href="/settings?tab=accounts" class="{{if eq $d.Tab "accounts"}}active{{end}}">Accounts</a>
<a href="/settings?tab=categories" class="{{if eq $d.Tab "categories"}}active{{end}}">Categories</a>
</div>
{{if eq $d.Tab "accounts"}}
<div class="s-card">
<h3>Add account</h3>
<form method="post" action="/accounts" id="add-account-form">
<div class="form-row">
<input type="text" name="name" placeholder="Account name" required>
<select name="type">
<option value="checking">Checking</option>
<option value="savings">Savings</option>
<option value="credit">Credit card</option>
<option value="securities">Securities</option>
</select>
<button type="submit" class="btn btn-primary">Add</button>
</div>
</form>
</div>
<div class="s-card">
{{if $d.Accounts}}
<table>
<thead><tr><th>Name</th><th>Type</th><th></th></tr></thead>
<tbody>
{{range $d.Accounts}}
<tr>
<td style="font-weight:500;">{{.Name}}</td>
<td><span class="type-badge">{{.Type}}</span></td>
<td style="text-align:right;">
<button class="btn btn-danger" onclick="deleteAccount('{{.ID}}')">Delete</button>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-state">No accounts yet — add one above.</div>
{{end}}
</div>
{{else}}{{/* categories tab */}}
<div class="s-card">
<h3>Add category</h3>
<form method="post" action="/categories" id="add-cat-form">
<div class="form-row">
<input type="text" name="name" placeholder="Category name" required>
<input type="number" name="budget_cents" placeholder="Monthly budget (cents)" min="0">
<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>
</div>
</form>
</div>
<div class="s-card">
{{if $d.Categories}}
<table>
<thead><tr><th>Category</th><th style="text-align:right;">Monthly Budget</th><th></th></tr></thead>
<tbody>
{{range $d.Categories}}
<tr id="cat-row-{{.ID}}">
<td>
<span class="color-dot" style="background:{{if .Color}}{{.Color}}{{else}}#9e9e9e{{end}};"></span>
<span id="cat-name-{{.ID}}">{{.Name}}</span>
</td>
<td style="text-align:right;">
{{if .BudgetCents}}
<span id="cat-budget-{{.ID}}">€{{cents .BudgetCents}}</span>
{{else}}
<span id="cat-budget-{{.ID}}" style="color:var(--muted);"></span>
{{end}}
<button onclick="editCat('{{.ID}}','{{.Name}}',{{.BudgetCents}},'{{.Color}}')"
style="margin-left:8px; background:none; border:none; cursor:pointer; color:var(--muted); font-size:0.85rem;">✎</button>
</td>
<td style="text-align:right;">
<button class="btn btn-danger" onclick="deleteCat('{{.ID}}')">Delete</button>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-state">No categories yet — add one above.</div>
{{end}}
</div>
{{end}}
<!-- Edit category modal -->
<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 style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
<span style="font-weight:700;">Edit Category</span>
<button onclick="closeEditCat()" style="background:none; border:none; cursor:pointer; color:var(--muted); font-size:1.1rem;"></button>
</div>
<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-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-color" type="color" style="width:100%; height:38px; padding:4px; border:1px solid var(--border); border-radius:8px; cursor:pointer;">
</div>
<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="saveEditCat()" class="btn btn-primary">Save</button>
</div>
</div>
</div>
<script>
let editingCatID = null;
function deleteAccount(id) {
if (!confirm('Delete this account?')) return;
fetch('/accounts/' + id, { method: 'DELETE' })
.then(r => { if (r.ok) location.reload(); });
}
function deleteCat(id) {
if (!confirm('Delete this category?')) return;
fetch('/categories/' + id, { method: 'DELETE' })
.then(r => { if (r.ok) location.reload(); });
}
function editCat(id, name, budgetCents, color) {
editingCatID = id;
document.getElementById('edit-cat-name').value = name;
document.getElementById('edit-cat-budget').value = budgetCents || '';
document.getElementById('edit-cat-color').value = color || '#6366f1';
document.getElementById('edit-cat-modal').style.display = 'flex';
}
function closeEditCat() {
document.getElementById('edit-cat-modal').style.display = 'none';
editingCatID = null;
}
function saveEditCat() {
if (!editingCatID) return;
const body = new URLSearchParams({
name: document.getElementById('edit-cat-name').value,
budget_cents: document.getElementById('edit-cat-budget').value || '0',
color: document.getElementById('edit-cat-color').value,
});
fetch('/categories/' + editingCatID, { method: 'PUT', body })
.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) {
e.preventDefault();
fetch(this.action, { method: 'POST', body: new FormData(this) })
.then(r => { if (r.ok || r.redirected) location.reload(); });
});
document.getElementById('add-cat-form')?.addEventListener('submit', function(e) {
e.preventDefault();
fetch(this.action, { method: 'POST', body: new FormData(this) })
.then(r => { if (r.ok || r.redirected) location.reload(); });
});
</script>
{{end}}