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:
parent
3dd7362964
commit
437fb5a2df
@ -125,6 +125,8 @@ var (
|
||||
taxTmpl = parseTmpl("templates/base.html", "templates/tax.html")
|
||||
householdTmpl = parseTmpl("templates/base.html", "templates/household.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 {
|
||||
@ -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) {
|
||||
mux.HandleFunc("GET /{$}", h.Dashboard)
|
||||
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/confirm", h.ImportConfirm)
|
||||
mux.HandleFunc("POST /import/securities", h.ImportSecurities)
|
||||
mux.HandleFunc("GET /accounts", h.Accounts)
|
||||
mux.HandleFunc("POST /accounts", h.Accounts)
|
||||
mux.HandleFunc("DELETE /accounts/{id}", h.Accounts)
|
||||
mux.HandleFunc("GET /categories", h.Categories)
|
||||
mux.HandleFunc("POST /categories", h.Categories)
|
||||
mux.HandleFunc("PUT /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("GET /networth", h.NetWorth)
|
||||
mux.HandleFunc("GET /simulator", h.Simulator)
|
||||
mux.HandleFunc("GET /sharing", h.Sharing)
|
||||
mux.HandleFunc("POST /sharing", h.Sharing)
|
||||
mux.HandleFunc("DELETE /sharing/{viewer_id}", h.Sharing)
|
||||
// legacy redirects so old bookmarks / links keep working
|
||||
mux.HandleFunc("GET /sharing", func(w http.ResponseWriter, r *http.Request) {
|
||||
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("POST /api/transactions", h.CreateTransaction)
|
||||
mux.HandleFunc("PUT /api/transactions/{id}", h.UpdateTransaction)
|
||||
mux.HandleFunc("DELETE /api/transactions/{id}", h.DeleteTransaction)
|
||||
mux.HandleFunc("GET /tax", h.Tax)
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@ -270,6 +270,44 @@ type Household struct {
|
||||
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 {
|
||||
UserID string
|
||||
Email string
|
||||
|
||||
@ -140,6 +140,48 @@
|
||||
.nav a:not(.nav-brand):hover { color: var(--text); background: var(--surface2); }
|
||||
.nav a.active { color: var(--accent2); background: var(--accent-glow); }
|
||||
.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 {
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
@ -391,21 +433,39 @@
|
||||
<div class="nav-brand-icon">₣</div>
|
||||
Finance
|
||||
</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="/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="/goals" class="{{if eq .Route "goals"}}active{{end}}">Goals</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="/tax" class="{{if eq .Route "tax"}}active{{end}}">Tax</a>
|
||||
<a href="/household" class="{{if eq .Route "household"}}active{{end}}">Household</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>
|
||||
|
||||
{{$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="/simulator" class="{{if eq .Route "simulator"}}active{{end}}">What If</a>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-spacer"></div>
|
||||
<span class="nav-email">{{.Email}}</span>
|
||||
<button class="theme-btn" id="theme-toggle" title="Toggle dark/light mode">🌙</button>
|
||||
|
||||
167
apps/finance/services/api/main/templates/people.html
Normal file
167
apps/finance/services/api/main/templates/people.html
Normal 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}}
|
||||
191
apps/finance/services/api/main/templates/settings.html
Normal file
191
apps/finance/services/api/main/templates/settings.html
Normal 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}}
|
||||
Loading…
x
Reference in New Issue
Block a user