feat(finance): dark mode UI overhaul + admin seed data
UI — full dark/light theme system: - CSS custom-property token system (--bg, --surface, --accent, --green, --red, etc.) with complete light-mode overrides via [data-theme="light"] - Sticky frosted-glass nav with animated brand icon, theme toggle persisted to localStorage, respects prefers-color-scheme on first visit - Cards with layered shadows, glass backdrop-filter, shimmer accent stripe on value cards - Glowing category color dots, colored P&L badges, budget bars with glow effect - All Chart.js instances use CSS-variable-aware grid/text colours - Scroll-reveal animations and animated money counters on every KPI card - 3-D donut portfolio chart recoloured to match palette; hover lifts the hovered slice - Accounts page shows type emoji icons; delete removes row in-place - Sharing page search dropdown themed with var() colours - Import preview: colour-coded left border on category select driven by category colour - Projections: second KPI card (monthly avg) + pace bars per category Seed data (seed.go): - SeedAdmin() runs in a goroutine at startup; idempotent (skips if transactions exist) - Resolves admin user ID via internal users service GET /admin/users?search=<email> (SEED_USER_EMAIL env var, defaults to admin@homelab.local) - Seeds 4 accounts (CGD Checking, CGD Savings, Visa Credit, Trade Republic) - Seeds 14 categories with colours and monthly budgets - Seeds ~65 realistic Portuguese household transactions spread across 6 months - Seeds 7 ETF buy trades across VWCE, SXR8 (S&P 500), EUNL (MSCI World) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0a2beb2973
commit
7a2cb10c79
@ -32,6 +32,9 @@ func main() {
|
|||||||
defer db.Close(ctx)
|
defer db.Close(ctx)
|
||||||
|
|
||||||
store := NewStore(db)
|
store := NewStore(db)
|
||||||
|
|
||||||
|
go SeedAdmin(ctx, store)
|
||||||
|
|
||||||
handler := NewHandler(store)
|
handler := NewHandler(store)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|||||||
314
apps/finance/services/api/main/seed.go
Normal file
314
apps/finance/services/api/main/seed.go
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SeedAdmin looks up the admin user by email (via the internal users service)
|
||||||
|
// and seeds demo data if the account has no existing transactions.
|
||||||
|
func SeedAdmin(ctx context.Context, store *Store) {
|
||||||
|
email := os.Getenv("SEED_USER_EMAIL")
|
||||||
|
if email == "" {
|
||||||
|
email = "admin@homelab.local"
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := lookupUserByEmail(email)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("seed: could not resolve admin user, skipping", "email", email, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// idempotent — skip if any transactions already exist
|
||||||
|
existing, err := store.getTransactions(ctx, userID, bson.M{})
|
||||||
|
if err == nil && len(existing) > 0 {
|
||||||
|
slog.Info("seed: data already present, skipping", "user_id", userID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("seed: seeding demo data", "user_id", userID, "email", email)
|
||||||
|
|
||||||
|
if err := seedAll(ctx, store, userID); err != nil {
|
||||||
|
slog.Error("seed: failed", "err", err)
|
||||||
|
} else {
|
||||||
|
slog.Info("seed: done")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupUserByEmail(email string) (string, error) {
|
||||||
|
usersURL := os.Getenv("USERS_SERVICE_URL")
|
||||||
|
if usersURL == "" {
|
||||||
|
usersURL = "http://users"
|
||||||
|
}
|
||||||
|
resp, err := http.Get(fmt.Sprintf("%s/admin/users?search=%s", usersURL, email))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("users service unreachable: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var users []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&users); err != nil {
|
||||||
|
return "", fmt.Errorf("decode users response: %w", err)
|
||||||
|
}
|
||||||
|
for _, u := range users {
|
||||||
|
if u.Email == email {
|
||||||
|
return u.ID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("user %q not found", email)
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedAll(ctx context.Context, store *Store, userID string) error {
|
||||||
|
// ── Accounts ─────────────────────────────────────────────────────────
|
||||||
|
checkingID := bson.NewObjectID().Hex()
|
||||||
|
savingsID := bson.NewObjectID().Hex()
|
||||||
|
creditID := bson.NewObjectID().Hex()
|
||||||
|
investID := bson.NewObjectID().Hex()
|
||||||
|
|
||||||
|
accounts := []*Account{
|
||||||
|
{ID: checkingID, UserID: userID, Name: "CGD Checking", Type: "checking"},
|
||||||
|
{ID: savingsID, UserID: userID, Name: "CGD Savings", Type: "savings"},
|
||||||
|
{ID: creditID, UserID: userID, Name: "Visa Credit", Type: "credit"},
|
||||||
|
{ID: investID, UserID: userID, Name: "Trade Republic", Type: "securities"},
|
||||||
|
}
|
||||||
|
for _, a := range accounts {
|
||||||
|
if err := store.createAccount(ctx, a); err != nil {
|
||||||
|
return fmt.Errorf("create account: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Categories with budgets ───────────────────────────────────────────
|
||||||
|
type catDef struct {
|
||||||
|
Name string
|
||||||
|
Color string
|
||||||
|
BudgetCents int64
|
||||||
|
}
|
||||||
|
catDefs := []catDef{
|
||||||
|
{"Groceries", "#4caf50", 30000},
|
||||||
|
{"Food", "#ff9800", 20000},
|
||||||
|
{"Transport", "#2196f3", 8000},
|
||||||
|
{"Housing", "#9c27b0", 80000},
|
||||||
|
{"Utilities", "#607d8b", 10000},
|
||||||
|
{"Health", "#f44336", 5000},
|
||||||
|
{"Clothing", "#e91e63", 10000},
|
||||||
|
{"Games", "#673ab7", 3000},
|
||||||
|
{"Entertainment", "#ff5722", 5000},
|
||||||
|
{"Subscriptions", "#795548", 4000},
|
||||||
|
{"Shopping", "#ff6f00", 15000},
|
||||||
|
{"Income", "#2e7d32", 0},
|
||||||
|
{"Investments", "#1565c0", 20000},
|
||||||
|
{"Others", "#9e9e9e", 5000},
|
||||||
|
}
|
||||||
|
catIDByName := make(map[string]string)
|
||||||
|
for _, cd := range catDefs {
|
||||||
|
id := bson.NewObjectID().Hex()
|
||||||
|
catIDByName[cd.Name] = id
|
||||||
|
cat := &Category{
|
||||||
|
ID: id,
|
||||||
|
UserID: userID,
|
||||||
|
Name: cd.Name,
|
||||||
|
Color: cd.Color,
|
||||||
|
BudgetCents: cd.BudgetCents,
|
||||||
|
}
|
||||||
|
if err := store.createCategory(ctx, cat); err != nil {
|
||||||
|
return fmt.Errorf("create category %s: %w", cd.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Transactions — 6 months of realistic Portuguese household spend ───
|
||||||
|
now := time.Now()
|
||||||
|
var txns []Transaction
|
||||||
|
|
||||||
|
type txDef struct {
|
||||||
|
daysAgo int
|
||||||
|
desc string
|
||||||
|
amountCents int64
|
||||||
|
cat string
|
||||||
|
accountID string
|
||||||
|
}
|
||||||
|
|
||||||
|
rawTxns := []txDef{
|
||||||
|
// ── Month 0 (current month) ──────────────────────────────────────
|
||||||
|
{0, "Salary — Homelab Corp", 230000, "Income", checkingID},
|
||||||
|
{1, "Continente Supermercado", -4230, "Groceries", checkingID},
|
||||||
|
{2, "MEO Internet", -3999, "Utilities", checkingID},
|
||||||
|
{3, "Glovo — Sushi House", -2150, "Food", creditID},
|
||||||
|
{4, "Uber Lisboa", -850, "Transport", creditID},
|
||||||
|
{5, "Steam — Elden Ring DLC", -2999, "Games", creditID},
|
||||||
|
{6, "Pingo Doce", -3120, "Groceries", checkingID},
|
||||||
|
{7, "Farmácia Saúde", -1540, "Health", checkingID},
|
||||||
|
{8, "Spotify Premium", -999, "Subscriptions", creditID},
|
||||||
|
{9, "Netflix", -1599, "Subscriptions", creditID},
|
||||||
|
{10, "Lidl Supermercado", -5640, "Groceries", checkingID},
|
||||||
|
{11, "Restaurante O Barrigas", -3280, "Food", creditID},
|
||||||
|
{12, "Trade Republic — VWCE Buy", -50000, "Investments", investID},
|
||||||
|
{13, "CP — Lisboa Cascais", -310, "Transport", creditID},
|
||||||
|
{14, "Decathlon", -4990, "Shopping", creditID},
|
||||||
|
{15, "Rendimento subarrendamento", 30000, "Income", checkingID},
|
||||||
|
// ── Month 1 ──────────────────────────────────────────────────────
|
||||||
|
{30, "Salary — Homelab Corp", 230000, "Income", checkingID},
|
||||||
|
{31, "Auchan Supermercado", -6780, "Groceries", checkingID},
|
||||||
|
{32, "EDP Energia", -6200, "Utilities", checkingID},
|
||||||
|
{33, "Renda apartamento", -80000, "Housing", checkingID},
|
||||||
|
{34, "McDonald's Marquês", -1190, "Food", creditID},
|
||||||
|
{35, "Zara — Coleção Verão", -8990, "Clothing", creditID},
|
||||||
|
{36, "Bolt ride", -620, "Transport", creditID},
|
||||||
|
{37, "NOS Telemóvel", -1799, "Utilities", checkingID},
|
||||||
|
{38, "Intermarché", -4420, "Groceries", checkingID},
|
||||||
|
{39, "Ginásio Holmes Place", -4900, "Health", checkingID},
|
||||||
|
{40, "Amazon Prime", -799, "Subscriptions", creditID},
|
||||||
|
{41, "Glovo — Burger King", -1890, "Food", creditID},
|
||||||
|
{42, "Trade Republic — SXR8 Buy", -30000, "Investments", investID},
|
||||||
|
{43, "Via Verde portagens", -920, "Transport", checkingID},
|
||||||
|
{44, "Fnac — Livros", -2350, "Shopping", creditID},
|
||||||
|
{45, "H&M online", -5490, "Clothing", creditID},
|
||||||
|
// ── Month 2 ──────────────────────────────────────────────────────
|
||||||
|
{60, "Salary — Homelab Corp", 230000, "Income", checkingID},
|
||||||
|
{61, "Continente Supermercado", -5120, "Groceries", checkingID},
|
||||||
|
{62, "MEO Internet", -3999, "Utilities", checkingID},
|
||||||
|
{63, "Renda apartamento", -80000, "Housing", checkingID},
|
||||||
|
{64, "Pastelaria Batalha", -480, "Food", creditID},
|
||||||
|
{65, "Uber Lisboa", -1240, "Transport", creditID},
|
||||||
|
{66, "Epic Games — Fortnite", -1999, "Games", creditID},
|
||||||
|
{67, "Lidl Supermercado", -4890, "Groceries", checkingID},
|
||||||
|
{68, "Farmácia da Baixa", -2310, "Health", checkingID},
|
||||||
|
{69, "Spotify Premium", -999, "Subscriptions", creditID},
|
||||||
|
{70, "Netflix", -1599, "Subscriptions", creditID},
|
||||||
|
{71, "Restaurante Eleven", -9400, "Food", creditID},
|
||||||
|
{72, "Trade Republic — VWCE Buy", -50000, "Investments", investID},
|
||||||
|
{73, "Teatro Nacional", -2500, "Entertainment", creditID},
|
||||||
|
{74, "IKEA Lisboa", -14900, "Shopping", creditID},
|
||||||
|
{75, "Pingo Doce", -3670, "Groceries", checkingID},
|
||||||
|
// ── Month 3 ──────────────────────────────────────────────────────
|
||||||
|
{90, "Salary — Homelab Corp", 230000, "Income", checkingID},
|
||||||
|
{91, "Auchan Supermercado", -7230, "Groceries", checkingID},
|
||||||
|
{92, "EDP Energia", -5800, "Utilities", checkingID},
|
||||||
|
{93, "Renda apartamento", -80000, "Housing", checkingID},
|
||||||
|
{94, "KFC Colombo", -1440, "Food", creditID},
|
||||||
|
{95, "Bolt ride", -740, "Transport", creditID},
|
||||||
|
{96, "NOS Telemóvel", -1799, "Utilities", checkingID},
|
||||||
|
{97, "Intermarché", -5560, "Groceries", checkingID},
|
||||||
|
{98, "Consulta médica particular", -8000, "Health", checkingID},
|
||||||
|
{99, "Disney+", -799, "Subscriptions", creditID},
|
||||||
|
{100, "Amazon Prime", -799, "Subscriptions", creditID},
|
||||||
|
{101, "Restaurante A Cevicheria", -6200, "Food", creditID},
|
||||||
|
{102, "Trade Republic — SXR8 Buy", -30000, "Investments", investID},
|
||||||
|
{103, "CP — InterCity Porto", -2450, "Transport", checkingID},
|
||||||
|
{104, "Livraria Bertrand", -1890, "Shopping", creditID},
|
||||||
|
// ── Month 4 ──────────────────────────────────────────────────────
|
||||||
|
{120, "Salary — Homelab Corp", 230000, "Income", checkingID},
|
||||||
|
{121, "Continente Supermercado", -6010, "Groceries", checkingID},
|
||||||
|
{122, "MEO Internet", -3999, "Utilities", checkingID},
|
||||||
|
{123, "Renda apartamento", -80000, "Housing", checkingID},
|
||||||
|
{124, "Glovo — Pizza Hut", -2340, "Food", creditID},
|
||||||
|
{125, "Uber Lisboa", -990, "Transport", creditID},
|
||||||
|
{126, "PlayStation Store", -2999, "Games", creditID},
|
||||||
|
{127, "Lidl Supermercado", -5210, "Groceries", checkingID},
|
||||||
|
{128, "Farmácia Saúde", -890, "Health", checkingID},
|
||||||
|
{129, "Spotify Premium", -999, "Subscriptions", creditID},
|
||||||
|
{130, "Netflix", -1599, "Subscriptions", creditID},
|
||||||
|
{131, "Tasca do Chico — jantar", -5400, "Food", creditID},
|
||||||
|
{132, "Trade Republic — VWCE Buy", -50000, "Investments", investID},
|
||||||
|
{133, "Fnac — AirPods", -17900, "Shopping", creditID},
|
||||||
|
{134, "Pingo Doce", -4120, "Groceries", checkingID},
|
||||||
|
// ── Month 5 ──────────────────────────────────────────────────────
|
||||||
|
{150, "Salary — Homelab Corp", 230000, "Income", checkingID},
|
||||||
|
{151, "Auchan Supermercado", -5890, "Groceries", checkingID},
|
||||||
|
{152, "EDP Energia", -6400, "Utilities", checkingID},
|
||||||
|
{153, "Renda apartamento", -80000, "Housing", checkingID},
|
||||||
|
{154, "Nando's Lisboa", -1980, "Food", creditID},
|
||||||
|
{155, "Bolt ride", -510, "Transport", creditID},
|
||||||
|
{156, "NOS Telemóvel", -1799, "Utilities", checkingID},
|
||||||
|
{157, "Intermarché", -4780, "Groceries", checkingID},
|
||||||
|
{158, "Óculos — Ótica Avenida", -12000, "Health", checkingID},
|
||||||
|
{159, "Disney+", -799, "Subscriptions", creditID},
|
||||||
|
{160, "Amazon Prime", -799, "Subscriptions", creditID},
|
||||||
|
{161, "Cinemateca Portuguesa", -600, "Entertainment", creditID},
|
||||||
|
{162, "Trade Republic — VWCE Buy", -50000, "Investments", investID},
|
||||||
|
{163, "Zara — Outono", -12490, "Clothing", creditID},
|
||||||
|
{164, "Worten — Monitor", -34900, "Shopping", creditID},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, td := range rawTxns {
|
||||||
|
date := now.AddDate(0, 0, -td.daysAgo).Truncate(24 * time.Hour)
|
||||||
|
txns = append(txns, Transaction{
|
||||||
|
ID: bson.NewObjectID().Hex(),
|
||||||
|
UserID: userID,
|
||||||
|
AccountID: td.accountID,
|
||||||
|
Date: date,
|
||||||
|
Description: td.desc,
|
||||||
|
AmountCents: td.amountCents,
|
||||||
|
Category: td.cat,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.createTransactions(ctx, txns); err != nil {
|
||||||
|
return fmt.Errorf("create transactions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Portfolio trades ─────────────────────────────────────────────────
|
||||||
|
trades := []Trade{
|
||||||
|
// VWCE — Vanguard FTSE All-World
|
||||||
|
{
|
||||||
|
ID: bson.NewObjectID().Hex(), UserID: userID,
|
||||||
|
ISIN: "IE00B3RBWM25", Name: "VWCE - Vanguard All-World",
|
||||||
|
Type: "buy", Quantity: 12, PriceCents: 11820, TotalCents: 141840,
|
||||||
|
Date: now.AddDate(0, -5, 5), CreatedAt: time.Now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: bson.NewObjectID().Hex(), UserID: userID,
|
||||||
|
ISIN: "IE00B3RBWM25", Name: "VWCE - Vanguard All-World",
|
||||||
|
Type: "buy", Quantity: 8, PriceCents: 11960, TotalCents: 95680,
|
||||||
|
Date: now.AddDate(0, -3, 12), CreatedAt: time.Now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: bson.NewObjectID().Hex(), UserID: userID,
|
||||||
|
ISIN: "IE00B3RBWM25", Name: "VWCE - Vanguard All-World",
|
||||||
|
Type: "buy", Quantity: 6, PriceCents: 12100, TotalCents: 72600,
|
||||||
|
Date: now.AddDate(0, -1, 12), CreatedAt: time.Now(),
|
||||||
|
},
|
||||||
|
// SXR8 — iShares Core S&P 500
|
||||||
|
{
|
||||||
|
ID: bson.NewObjectID().Hex(), UserID: userID,
|
||||||
|
ISIN: "IE00B5BMR087", Name: "SXR8 - iShares S&P 500",
|
||||||
|
Type: "buy", Quantity: 15, PriceCents: 53200, TotalCents: 798000,
|
||||||
|
Date: now.AddDate(0, -4, 8), CreatedAt: time.Now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: bson.NewObjectID().Hex(), UserID: userID,
|
||||||
|
ISIN: "IE00B5BMR087", Name: "SXR8 - iShares S&P 500",
|
||||||
|
Type: "buy", Quantity: 10, PriceCents: 55100, TotalCents: 551000,
|
||||||
|
Date: now.AddDate(0, -2, 3), CreatedAt: time.Now(),
|
||||||
|
},
|
||||||
|
// EUNL — iShares Core MSCI World
|
||||||
|
{
|
||||||
|
ID: bson.NewObjectID().Hex(), UserID: userID,
|
||||||
|
ISIN: "IE00B4L5Y983", Name: "EUNL - iShares MSCI World",
|
||||||
|
Type: "buy", Quantity: 20, PriceCents: 8950, TotalCents: 179000,
|
||||||
|
Date: now.AddDate(0, -5, 20), CreatedAt: time.Now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: bson.NewObjectID().Hex(), UserID: userID,
|
||||||
|
ISIN: "IE00B4L5Y983", Name: "EUNL - iShares MSCI World",
|
||||||
|
Type: "buy", Quantity: 10, PriceCents: 9210, TotalCents: 92100,
|
||||||
|
Date: now.AddDate(0, -2, 18), CreatedAt: time.Now(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.createTrades(ctx, trades); err != nil {
|
||||||
|
return fmt.Errorf("create trades: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@ -1,14 +1,15 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
{{$d := .}}
|
{{$d := .}}
|
||||||
<h1 style="margin-bottom: 24px;">Accounts</h1>
|
<h1 style="margin-bottom:24px;">Accounts</h1>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card animate-on-scroll">
|
||||||
<form method="POST" class="flex flex-wrap" style="gap: 12px; align-items: end;">
|
<h2 style="margin-bottom:16px;">Add Account</h2>
|
||||||
<div class="form-group" style="margin-bottom: 0; flex: 1;">
|
<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;">
|
||||||
<label>Account Name</label>
|
<label>Account Name</label>
|
||||||
<input type="text" name="name" placeholder="e.g. CGD Current Account" required>
|
<input type="text" name="name" placeholder="e.g. CGD Checking" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="margin-bottom: 0; flex: 1;">
|
<div class="form-group" style="margin-bottom:0; min-width:160px;">
|
||||||
<label>Type</label>
|
<label>Type</label>
|
||||||
<select name="type">
|
<select name="type">
|
||||||
<option value="checking">Checking</option>
|
<option value="checking">Checking</option>
|
||||||
@ -17,27 +18,41 @@
|
|||||||
<option value="securities">Securities</option>
|
<option value="securities">Securities</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary">Add Account</button>
|
<button type="submit" class="btn btn-primary">Add</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card animate-on-scroll">
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Name</th><th>Type</th><th></th></tr>
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range $d.Accounts}}
|
{{range $d.Accounts}}
|
||||||
<tr>
|
<tr id="acct-row-{{.ID}}">
|
||||||
<td>{{.Name}}</td>
|
<td style="font-weight:500;">{{.Name}}</td>
|
||||||
<td><span class="badge">{{.Type}}</span></td>
|
|
||||||
<td>
|
<td>
|
||||||
|
{{$icon := "🏦"}}
|
||||||
|
{{if eq .Type "savings"}}{{$icon = "🏧"}}{{end}}
|
||||||
|
{{if eq .Type "credit"}}{{$icon = "💳"}}{{end}}
|
||||||
|
{{if eq .Type "securities"}}{{$icon = "📈"}}{{end}}
|
||||||
|
<span class="badge" style="background:var(--bg3); color:var(--text2); border:1px solid var(--border2);">
|
||||||
|
{{$icon}} {{.Type}}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<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}}')">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr><td colspan="3" class="text-center text-muted">No accounts yet.</td></tr>
|
<tr>
|
||||||
|
<td colspan="3" class="text-center text-muted" style="padding:36px;">No accounts yet.</td>
|
||||||
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -47,7 +62,9 @@
|
|||||||
<script>
|
<script>
|
||||||
function delAccount(id) {
|
function delAccount(id) {
|
||||||
if (!confirm('Delete this account?')) return;
|
if (!confirm('Delete this account?')) return;
|
||||||
fetch('/accounts/' + id, {method: 'DELETE'}).then(() => location.reload());
|
fetch('/accounts/' + id, {method: 'DELETE'}).then(r => {
|
||||||
|
if (r.ok) document.getElementById('acct-row-' + id).remove();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@ -1,214 +1,474 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" data-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{if .Title}}{{.Title}} — {{end}}Finance</title>
|
<title>{{if .Title}}{{.Title}} — {{end}}Finance</title>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||||
<style>
|
<style>
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
/* ── Tokens ─────────────────────────────────────────────────────── */
|
||||||
|
:root {
|
||||||
|
--bg: #0f1117;
|
||||||
|
--bg2: #161b27;
|
||||||
|
--bg3: #1e2535;
|
||||||
|
--surface: rgba(30, 37, 53, 0.85);
|
||||||
|
--surface2: rgba(40, 50, 72, 0.7);
|
||||||
|
--border: rgba(255,255,255,0.07);
|
||||||
|
--border2: rgba(255,255,255,0.12);
|
||||||
|
--text: #e8eaf6;
|
||||||
|
--text2: #9fa8c7;
|
||||||
|
--text3: #5c6585;
|
||||||
|
--accent: #6979f8;
|
||||||
|
--accent2: #8b9ffc;
|
||||||
|
--accent-glow: rgba(105,121,248,0.25);
|
||||||
|
--green: #4ade80;
|
||||||
|
--red: #f87171;
|
||||||
|
--green-dim: rgba(74,222,128,0.15);
|
||||||
|
--red-dim: rgba(248,113,113,0.15);
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3);
|
||||||
|
--shadow-md: 0 4px 16px rgba(0,0,0,0.5), 0 2px 6px rgba(0,0,0,0.3);
|
||||||
|
--shadow-lg: 0 12px 40px rgba(0,0,0,0.6), 0 4px 12px rgba(0,0,0,0.4);
|
||||||
|
--radius: 14px;
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--nav-h: 58px;
|
||||||
|
}
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg: #f0f2f8;
|
||||||
|
--bg2: #e4e8f4;
|
||||||
|
--bg3: #d8ddf0;
|
||||||
|
--surface: rgba(255,255,255,0.9);
|
||||||
|
--surface2: rgba(240,242,248,0.8);
|
||||||
|
--border: rgba(0,0,0,0.07);
|
||||||
|
--border2: rgba(0,0,0,0.12);
|
||||||
|
--text: #1a1f36;
|
||||||
|
--text2: #4a5275;
|
||||||
|
--text3: #8a92b0;
|
||||||
|
--accent: #4355e8;
|
||||||
|
--accent2: #6373f0;
|
||||||
|
--accent-glow: rgba(67,85,232,0.18);
|
||||||
|
--green: #16a34a;
|
||||||
|
--red: #dc2626;
|
||||||
|
--green-dim: rgba(22,163,74,0.1);
|
||||||
|
--red-dim: rgba(220,38,38,0.1);
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.05);
|
||||||
|
--shadow-md: 0 4px 16px rgba(0,0,0,0.1), 0 2px 6px rgba(0,0,0,0.06);
|
||||||
|
--shadow-lg: 0 12px 40px rgba(0,0,0,0.12), 0 4px 12px rgba(0,0,0,0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Reset & base ────────────────────────────────────────────────── */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
background: linear-gradient(135deg, #f5f7fa 0%, #e4e9f2 100%);
|
background: var(--bg);
|
||||||
color: #333; min-height: 100vh;
|
color: var(--text);
|
||||||
animation: fadeIn 0.5s ease-out;
|
min-height: 100vh;
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
transition: background 0.3s ease, color 0.3s ease;
|
||||||
}
|
}
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; transform: translateY(8px); }
|
/* Subtle grid texture overlay */
|
||||||
to { opacity: 1; transform: translateY(0); }
|
body::before {
|
||||||
}
|
content: '';
|
||||||
@keyframes slideInRow {
|
position: fixed;
|
||||||
from { opacity: 0; transform: translateX(-12px); }
|
inset: 0;
|
||||||
to { opacity: 1; transform: translateX(0); }
|
background-image:
|
||||||
}
|
radial-gradient(ellipse 80% 60% at 20% 10%, rgba(105,121,248,0.08) 0%, transparent 60%),
|
||||||
@keyframes pulse {
|
radial-gradient(ellipse 60% 50% at 80% 80%, rgba(139,159,252,0.05) 0%, transparent 55%);
|
||||||
0%, 100% { transform: scale(1); }
|
pointer-events: none;
|
||||||
50% { transform: scale(1.05); }
|
z-index: 0;
|
||||||
}
|
|
||||||
@keyframes shimmer {
|
|
||||||
0% { background-position: -200% center; }
|
|
||||||
100% { background-position: 200% center; }
|
|
||||||
}
|
}
|
||||||
|
body > * { position: relative; z-index: 1; }
|
||||||
|
|
||||||
|
/* ── Animations ──────────────────────────────────────────────────── */
|
||||||
|
@keyframes fadeUp { from { opacity:0; transform:translateY(12px); } to { opacity:1; transform:translateY(0); } }
|
||||||
|
@keyframes slideIn { from { opacity:0; transform:translateX(-10px); } to { opacity:1; transform:translateX(0); } }
|
||||||
|
@keyframes shimmer { 0% { background-position:-200% center; } 100% { background-position:200% center; } }
|
||||||
|
@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.6; } }
|
||||||
|
@keyframes spin { to { transform:rotate(360deg); } }
|
||||||
|
|
||||||
|
/* ── Nav ─────────────────────────────────────────────────────────── */
|
||||||
.nav {
|
.nav {
|
||||||
background: linear-gradient(135deg, #1a237e, #283593);
|
height: var(--nav-h);
|
||||||
color: #fff; padding: 0 24px;
|
background: rgba(15,17,23,0.85);
|
||||||
display: flex; align-items: center; height: 56px; gap: 24px;
|
backdrop-filter: blur(16px) saturate(180%);
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||||
position: sticky; top: 0; z-index: 100;
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 28px;
|
||||||
|
gap: 4px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 200;
|
||||||
|
box-shadow: 0 1px 0 var(--border), 0 4px 20px rgba(0,0,0,0.3);
|
||||||
}
|
}
|
||||||
.nav a {
|
[data-theme="light"] .nav {
|
||||||
color: #c5cae9; text-decoration: none; font-size: 14px; font-weight: 500;
|
background: rgba(240,242,248,0.88);
|
||||||
position: relative; transition: color 0.2s ease;
|
box-shadow: 0 1px 0 var(--border), 0 4px 20px rgba(0,0,0,0.08);
|
||||||
}
|
}
|
||||||
.nav a::after {
|
.nav-brand {
|
||||||
content: ''; position: absolute; bottom: -4px; left: 0; right: 0;
|
font-size: 17px;
|
||||||
height: 2px; background: #fff; border-radius: 1px;
|
font-weight: 700;
|
||||||
transform: scaleX(0); transition: transform 0.25s ease;
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-right: 16px;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
}
|
}
|
||||||
.nav a:hover { color: #fff; }
|
.nav-brand-icon {
|
||||||
.nav a:hover::after { transform: scaleX(1); }
|
width: 28px; height: 28px;
|
||||||
.nav a.active { color: #fff; }
|
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||||
.nav a.active::after { transform: scaleX(1); }
|
border-radius: 7px;
|
||||||
.nav .brand { font-size: 18px; font-weight: 700; color: #fff; margin-right: auto; }
|
display: flex; align-items: center; justify-content: center;
|
||||||
.nav .brand::after { display: none; }
|
font-size: 14px;
|
||||||
.nav .email { font-size: 12px; color: #9fa8da; }
|
box-shadow: 0 2px 8px var(--accent-glow);
|
||||||
.container { max-width: 1200px; margin: 0 auto; padding: 24px; }
|
}
|
||||||
|
.nav a:not(.nav-brand) {
|
||||||
|
color: var(--text2);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: all 0.18s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.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-email {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text3);
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
.theme-btn {
|
||||||
|
width: 34px; height: 34px;
|
||||||
|
border-radius: 9px;
|
||||||
|
border: 1px solid var(--border2);
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--text2);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 15px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
transition: all 0.18s ease;
|
||||||
|
}
|
||||||
|
.theme-btn:hover { color: var(--text); background: var(--bg3); transform: scale(1.05); }
|
||||||
|
|
||||||
|
/* ── Layout ──────────────────────────────────────────────────────── */
|
||||||
|
.container {
|
||||||
|
max-width: 1240px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 28px 24px 48px;
|
||||||
|
animation: fadeUp 0.4s ease-out both;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
letter-spacing: -0.4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cards ───────────────────────────────────────────────────────── */
|
||||||
.card {
|
.card {
|
||||||
background: rgba(255,255,255,0.95); backdrop-filter: blur(8px);
|
background: var(--surface);
|
||||||
border-radius: 12px; padding: 24px; margin-bottom: 16px;
|
border: 1px solid var(--border);
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.06);
|
border-radius: var(--radius);
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
padding: 22px;
|
||||||
animation: fadeIn 0.4s ease-out both;
|
margin-bottom: 16px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
transition: box-shadow 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
|
||||||
}
|
}
|
||||||
.card:hover {
|
.card:hover {
|
||||||
transform: translateY(-2px);
|
box-shadow: var(--shadow-md);
|
||||||
box-shadow: 0 8px 25px rgba(0,0,0,0.1), 0 2px 8px rgba(0,0,0,0.06);
|
border-color: var(--border2);
|
||||||
}
|
}
|
||||||
.card h2 { font-size: 16px; color: #666; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; }
|
.card h2 {
|
||||||
.card .value { font-size: 28px; font-weight: 700; }
|
font-size: 11px;
|
||||||
.card .value.positive { color: #4caf50; }
|
font-weight: 600;
|
||||||
.card .value.negative { color: #f44336; }
|
color: var(--text3);
|
||||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 16px; }
|
text-transform: uppercase;
|
||||||
.table-wrap { overflow-x: auto; border-radius: 8px; }
|
letter-spacing: 0.8px;
|
||||||
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.card .value {
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.8px;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* value cards with colored top accent */
|
||||||
|
.value-card { position: relative; overflow: hidden; }
|
||||||
|
.value-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: linear-gradient(135deg, var(--accent-glow), transparent 60%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.value-card::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, var(--accent), var(--accent2), transparent);
|
||||||
|
border-radius: var(--radius) var(--radius) 0 0;
|
||||||
|
background-size: 200% auto;
|
||||||
|
animation: shimmer 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Colour utilities ────────────────────────────────────────────── */
|
||||||
|
.positive { color: var(--green); }
|
||||||
|
.negative { color: var(--red); }
|
||||||
|
|
||||||
|
/* ── Grid ────────────────────────────────────────────────────────── */
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; margin-bottom: 16px; }
|
||||||
|
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 16px; }
|
||||||
|
|
||||||
|
/* ── Tables ──────────────────────────────────────────────────────── */
|
||||||
|
.table-wrap { overflow-x: auto; border-radius: var(--radius-sm); }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
|
||||||
th {
|
th {
|
||||||
text-align: left; padding: 10px 12px; border-bottom: 2px solid #e0e0e0;
|
text-align: left;
|
||||||
color: #666; font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px;
|
padding: 9px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text3);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
border-bottom: 1px solid var(--border2);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
td { padding: 10px 12px; border-bottom: 1px solid #f0f0f0; }
|
td {
|
||||||
tbody tr {
|
padding: 10px 12px;
|
||||||
animation: slideInRow 0.3s ease-out both;
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
color: var(--text);
|
||||||
tbody tr:nth-child(1) { animation-delay: 0.02s; }
|
vertical-align: middle;
|
||||||
tbody tr:nth-child(2) { animation-delay: 0.04s; }
|
|
||||||
tbody tr:nth-child(3) { animation-delay: 0.06s; }
|
|
||||||
tbody tr:nth-child(4) { animation-delay: 0.08s; }
|
|
||||||
tbody tr:nth-child(5) { animation-delay: 0.10s; }
|
|
||||||
tbody tr:nth-child(6) { animation-delay: 0.12s; }
|
|
||||||
tbody tr:nth-child(7) { animation-delay: 0.14s; }
|
|
||||||
tbody tr:nth-child(8) { animation-delay: 0.16s; }
|
|
||||||
tbody tr:nth-child(9) { animation-delay: 0.18s; }
|
|
||||||
tbody tr:nth-child(10) { animation-delay: 0.20s; }
|
|
||||||
tbody tr:nth-child(11) { animation-delay: 0.22s; }
|
|
||||||
tbody tr:nth-child(12) { animation-delay: 0.24s; }
|
|
||||||
tr:hover td { background: #f5f7ff; }
|
|
||||||
.badge {
|
|
||||||
display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 12px; font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
tbody tr { transition: background 0.12s ease; }
|
||||||
|
tbody tr:hover td { background: var(--surface2); }
|
||||||
|
tbody tr:last-child td { border-bottom: none; }
|
||||||
|
.cents { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* ── Buttons ─────────────────────────────────────────────────────── */
|
||||||
.btn {
|
.btn {
|
||||||
display: inline-block; padding: 8px 16px; border-radius: 8px; font-size: 14px; font-weight: 500;
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
border: none; cursor: pointer; text-decoration: none;
|
padding: 8px 16px;
|
||||||
transition: all 0.2s ease;
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.18s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
.btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
|
.btn:hover { transform: translateY(-1px); }
|
||||||
.btn:active { transform: translateY(0); }
|
.btn:active { transform: translateY(0); }
|
||||||
.btn-primary { background: linear-gradient(135deg, #3949ab, #5c6bc0); color: #fff; }
|
.btn-primary {
|
||||||
.btn-primary:hover { background: linear-gradient(135deg, #303f9f, #3949ab); }
|
background: linear-gradient(135deg, var(--accent) 0%, var(--accent2) 100%);
|
||||||
.btn-danger { background: linear-gradient(135deg, #e53935, #ef5350); color: #fff; }
|
color: #fff;
|
||||||
.btn-outline { background: transparent; border: 1px solid #c5cae9; color: #3949ab; }
|
box-shadow: 0 2px 10px var(--accent-glow);
|
||||||
.btn-outline:hover { background: #f5f7ff; border-color: #3949ab; }
|
|
||||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
|
||||||
.form-group { margin-bottom: 16px; }
|
|
||||||
.form-group label { display: block; font-size: 14px; font-weight: 500; margin-bottom: 4px; color: #555; }
|
|
||||||
.form-group input, .form-group select {
|
|
||||||
width: 100%; padding: 8px 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px;
|
|
||||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
|
||||||
}
|
}
|
||||||
.form-group input:focus, .form-group select:focus {
|
.btn-primary:hover { box-shadow: 0 4px 18px var(--accent-glow); }
|
||||||
outline: none; border-color: #3949ab; box-shadow: 0 0 0 3px rgba(57,73,171,0.1);
|
.btn-danger {
|
||||||
|
background: var(--red-dim);
|
||||||
|
color: var(--red);
|
||||||
|
border: 1px solid rgba(248,113,113,0.2);
|
||||||
}
|
}
|
||||||
|
.btn-danger:hover { background: rgba(248,113,113,0.25); }
|
||||||
|
.btn-outline {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border2);
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
.btn-outline:hover { background: var(--surface2); color: var(--text); border-color: var(--accent); }
|
||||||
|
.btn-sm { padding: 4px 10px; font-size: 12px; border-radius: 6px; }
|
||||||
|
|
||||||
|
/* ── Forms ───────────────────────────────────────────────────────── */
|
||||||
|
.form-group { margin-bottom: 14px; }
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text2);
|
||||||
|
margin-bottom: 5px;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
.form-group input,
|
||||||
|
.form-group select,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 1px solid var(--border2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 13.5px;
|
||||||
|
background: var(--bg2);
|
||||||
|
color: var(--text);
|
||||||
|
transition: border-color 0.18s ease, box-shadow 0.18s ease;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||||
|
}
|
||||||
|
input[type="color"] { padding: 4px; height: 38px; cursor: pointer; }
|
||||||
|
select option { background: var(--bg2); color: var(--text); }
|
||||||
|
|
||||||
|
/* ── Badges ──────────────────────────────────────────────────────── */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
.category-dot {
|
||||||
|
width: 7px; height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty states ────────────────────────────────────────────────── */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 52px 24px;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
.empty-state h3 { font-size: 17px; color: var(--text2); margin-bottom: 8px; }
|
||||||
|
|
||||||
|
/* ── Misc utils ──────────────────────────────────────────────────── */
|
||||||
.flex { display: flex; gap: 8px; align-items: center; }
|
.flex { display: flex; gap: 8px; align-items: center; }
|
||||||
.flex-wrap { flex-wrap: wrap; }
|
.flex-wrap { flex-wrap: wrap; }
|
||||||
.mb-16 { margin-bottom: 16px; }
|
.mb-16 { margin-bottom: 16px; }
|
||||||
.mb-8 { margin-bottom: 8px; }
|
.mb-8 { margin-bottom: 8px; }
|
||||||
.mt-16 { margin-top: 16px; }
|
.mt-16 { margin-top: 16px; }
|
||||||
.text-center { text-align: center; }
|
.text-center { text-align: center; }
|
||||||
.text-right { text-align: right; }
|
.text-right { text-align: right; }
|
||||||
.text-muted { color: #999; font-size: 12px; }
|
.text-muted { color: var(--text3); font-size: 12px; }
|
||||||
.error { color: #f44336; font-size: 14px; margin-bottom: 16px; }
|
.error { color: var(--red); font-size: 13px; margin-bottom: 12px; }
|
||||||
.success { color: #4caf50; font-size: 14px; margin-bottom: 16px; }
|
.success { color: var(--green); font-size: 13px; margin-bottom: 12px; }
|
||||||
.empty-state { text-align: center; padding: 48px; color: #999; }
|
|
||||||
.empty-state h3 { font-size: 18px; margin-bottom: 8px; color: #666; }
|
/* ── Scroll-reveal ───────────────────────────────────────────────── */
|
||||||
table .cents { text-align: right; font-variant-numeric: tabular-nums; }
|
|
||||||
.category-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 6px; }
|
|
||||||
.tag { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 12px; color: #fff; }
|
|
||||||
.value-card { position: relative; overflow: hidden; }
|
|
||||||
.value-card::before {
|
|
||||||
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px;
|
|
||||||
background: linear-gradient(90deg, #3949ab, #5c6bc0, #7986cb);
|
|
||||||
background-size: 200% auto; animation: shimmer 3s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
.chart-container { position: relative; width: 100%; }
|
|
||||||
.animate-on-scroll {
|
.animate-on-scroll {
|
||||||
opacity: 0; transform: translateY(24px); transition: opacity 0.6s ease-out, transform 0.6s ease-out;
|
opacity: 0; transform: translateY(18px);
|
||||||
|
transition: opacity 0.5s ease-out, transform 0.5s ease-out;
|
||||||
}
|
}
|
||||||
.animate-on-scroll.visible { opacity: 1; transform: translateY(0); }
|
.animate-on-scroll.visible { opacity: 1; transform: translateY(0); }
|
||||||
.animate-on-scroll:nth-child(2) { transition-delay: 0.1s; }
|
.animate-on-scroll:nth-child(2) { transition-delay: 0.07s; }
|
||||||
.animate-on-scroll:nth-child(3) { transition-delay: 0.2s; }
|
.animate-on-scroll:nth-child(3) { transition-delay: 0.14s; }
|
||||||
.animate-on-scroll:nth-child(4) { transition-delay: 0.3s; }
|
.animate-on-scroll:nth-child(4) { transition-delay: 0.21s; }
|
||||||
.animate-on-scroll:nth-child(5) { transition-delay: 0.4s; }
|
.animate-on-scroll:nth-child(5) { transition-delay: 0.28s; }
|
||||||
@media (max-width: 768px) {
|
|
||||||
.nav { padding: 0 12px; gap: 12px; overflow-x: auto; }
|
/* ── Responsive ──────────────────────────────────────────────────── */
|
||||||
.container { padding: 12px; }
|
@media (max-width: 900px) {
|
||||||
.grid { grid-template-columns: 1fr; }
|
.grid-2 { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.nav { padding: 0 12px; gap: 2px; overflow-x: auto; }
|
||||||
|
.nav a:not(.nav-brand) { padding: 6px 7px; font-size: 12px; }
|
||||||
|
.container { padding: 16px 12px 32px; }
|
||||||
|
.grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
.card { padding: 16px; }
|
||||||
|
.nav-email { display: none; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<a href="/" class="brand">Finance</a>
|
<a href="/" class="nav-brand">
|
||||||
<a href="/" class="{{if eq .Route "dashboard"}}active{{end}}">Dashboard</a>
|
<div class="nav-brand-icon">₣</div>
|
||||||
|
Finance
|
||||||
|
</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="/import" class="{{if eq .Route "import"}}active{{end}}">Import</a>
|
||||||
<a href="/accounts" class="{{if eq .Route "accounts"}}active{{end}}">Accounts</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="/categories" class="{{if eq .Route "categories"}}active{{end}}">Categories</a>
|
||||||
<a href="/reports" class="{{if eq .Route "reports"}}active{{end}}">Reports</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="/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="/sharing" class="{{if eq .Route "sharing"}}active{{end}}">Sharing</a>
|
<a href="/sharing" class="{{if eq .Route "sharing"}}active{{end}}">Sharing</a>
|
||||||
<span class="email">{{.Email}}</span>
|
<div class="nav-spacer"></div>
|
||||||
|
<span class="nav-email">{{.Email}}</span>
|
||||||
|
<button class="theme-btn" id="theme-toggle" title="Toggle dark/light mode">🌙</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
{{block "content" .}}{{end}}
|
{{block "content" .}}{{end}}
|
||||||
</div>
|
</div>
|
||||||
<script>
|
|
||||||
function animateCounter(el) {
|
|
||||||
const target = parseFloat(el.getAttribute('data-target'));
|
|
||||||
const suffix = el.getAttribute('data-suffix') || '';
|
|
||||||
const prefix = el.getAttribute('data-prefix') || '';
|
|
||||||
const duration = parseInt(el.getAttribute('data-duration')) || 800;
|
|
||||||
const isPct = el.getAttribute('data-pct') !== null;
|
|
||||||
const start = performance.now();
|
|
||||||
|
|
||||||
function update(now) {
|
<script>
|
||||||
const t = Math.min((now - start) / duration, 1);
|
/* ── Theme toggle ─────────────────────────────────────────────── */
|
||||||
const eased = 1 - Math.pow(1 - t, 3);
|
const html = document.documentElement;
|
||||||
const current = target * eased;
|
const btn = document.getElementById('theme-toggle');
|
||||||
if (isPct) {
|
|
||||||
el.textContent = prefix + (current >= 0 ? '+' : '') + current.toFixed(2) + '%' + suffix;
|
function applyTheme(t) {
|
||||||
} else {
|
html.setAttribute('data-theme', t);
|
||||||
el.textContent = prefix + Math.round(current).toLocaleString() + suffix;
|
btn.textContent = t === 'dark' ? '☀️' : '🌙';
|
||||||
}
|
localStorage.setItem('theme', t);
|
||||||
if (t < 1) requestAnimationFrame(update);
|
|
||||||
}
|
|
||||||
requestAnimationFrame(update);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
const saved = localStorage.getItem('theme') ||
|
||||||
entries.forEach(entry => {
|
(window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
|
||||||
if (entry.isIntersecting) {
|
applyTheme(saved);
|
||||||
entry.target.classList.add('visible');
|
|
||||||
const counter = entry.target.querySelector('.animate-counter');
|
|
||||||
if (counter && !counter.dataset.counted) {
|
|
||||||
counter.dataset.counted = 'true';
|
|
||||||
animateCounter(counter);
|
|
||||||
}
|
|
||||||
observer.unobserve(entry.target);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, { threshold: 0.1 });
|
|
||||||
|
|
||||||
document.querySelectorAll('.animate-on-scroll').forEach(el => observer.observe(el));
|
btn.addEventListener('click', () =>
|
||||||
|
applyTheme(html.getAttribute('data-theme') === 'dark' ? 'light' : 'dark'));
|
||||||
|
|
||||||
|
/* ── Animated counter ─────────────────────────────────────────── */
|
||||||
|
function animateCounter(el) {
|
||||||
|
const target = parseFloat(el.getAttribute('data-target'));
|
||||||
|
const prefix = el.getAttribute('data-prefix') || '';
|
||||||
|
const duration = parseInt(el.getAttribute('data-duration')) || 900;
|
||||||
|
const start = performance.now();
|
||||||
|
function step(now) {
|
||||||
|
const t = Math.min((now - start) / duration, 1);
|
||||||
|
const e = 1 - Math.pow(1 - t, 3);
|
||||||
|
const v = target * e;
|
||||||
|
const abs = Math.abs(v);
|
||||||
|
const sign = v < 0 ? '−' : (target > 0 ? '+' : '');
|
||||||
|
el.textContent = prefix + sign + (abs / 100).toLocaleString('pt-PT', {minimumFractionDigits:2, maximumFractionDigits:2});
|
||||||
|
if (t < 1) requestAnimationFrame(step);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scroll reveal + counter trigger ─────────────────────────── */
|
||||||
|
const io = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (!entry.isIntersecting) return;
|
||||||
|
entry.target.classList.add('visible');
|
||||||
|
entry.target.querySelectorAll('.animate-counter').forEach(c => {
|
||||||
|
if (!c.dataset.counted) { c.dataset.counted = '1'; animateCounter(c); }
|
||||||
|
});
|
||||||
|
io.unobserve(entry.target);
|
||||||
|
});
|
||||||
|
}, { threshold: 0.08 });
|
||||||
|
|
||||||
|
document.querySelectorAll('.animate-on-scroll').forEach(el => io.observe(el));
|
||||||
|
|
||||||
|
/* ── Chart.js defaults for dark/light ─────────────────────────── */
|
||||||
|
function getThemeColor(v) {
|
||||||
|
const dark = html.getAttribute('data-theme') === 'dark';
|
||||||
|
return {
|
||||||
|
gridColor: dark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.06)',
|
||||||
|
textColor: dark ? '#5c6585' : '#9fa8c7',
|
||||||
|
}[v];
|
||||||
|
}
|
||||||
|
Chart.defaults.color = () => getThemeColor('textColor');
|
||||||
|
Chart.defaults.borderColor = () => getThemeColor('gridColor');
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -4,14 +4,14 @@
|
|||||||
|
|
||||||
<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;">Add Category</h2>
|
||||||
<form method="POST" class="flex flex-wrap" style="gap:12px; 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>Name</label>
|
||||||
<input type="text" name="name" placeholder="e.g. Dining" required>
|
<input type="text" name="name" placeholder="e.g. Dining" 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>Color</label>
|
||||||
<input type="color" name="color" value="#7986CB" style="padding:4px; height:38px;">
|
<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">Add</button>
|
||||||
</form>
|
</form>
|
||||||
@ -22,7 +22,7 @@
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width:40px;">Color</th>
|
<th style="width:44px;"></th>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Monthly Budget</th>
|
<th>Monthly Budget</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
@ -32,28 +32,35 @@
|
|||||||
{{range $d.Categories}}
|
{{range $d.Categories}}
|
||||||
<tr id="cat-row-{{.ID}}">
|
<tr id="cat-row-{{.ID}}">
|
||||||
<td>
|
<td>
|
||||||
<span style="display:inline-block; width:22px; height:22px; border-radius:50%; background:{{.Color}}; border:2px solid {{.Color}}44; vertical-align:middle;"></span>
|
<span style="display:inline-block; width:24px; height:24px; border-radius:50%;
|
||||||
|
background:{{.Color}}; box-shadow:0 0 10px {{.Color}}66;"></span>
|
||||||
</td>
|
</td>
|
||||||
<td style="font-weight:500;">{{.Name}}</td>
|
<td style="font-weight:500;">{{.Name}}</td>
|
||||||
<td>
|
<td>
|
||||||
<span id="budget-display-{{.ID}}" style="font-size:14px; color:#444;">
|
<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 set</span>{{end}}
|
{{if gt .BudgetCents 0}}€{{cents .BudgetCents}}{{else}}<span class="text-muted">No budget</span>{{end}}
|
||||||
</span>
|
</span>
|
||||||
<span id="budget-edit-{{.ID}}" style="display:none;">
|
<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:100px; padding:4px 8px; border:1px solid #c5cae9; border-radius:6px; font-size:13px;">
|
style="width:110px; padding:5px 9px; border:1px solid var(--border2);
|
||||||
|
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}}')">Save</button>
|
||||||
<button class="btn btn-outline btn-sm" onclick="cancelBudget('{{.ID}}')">Cancel</button>
|
<button class="btn btn-outline btn-sm" onclick="cancelBudget('{{.ID}}')">Cancel</button>
|
||||||
</span>
|
</span>
|
||||||
<button class="btn btn-outline btn-sm" id="budget-btn-{{.ID}}" onclick="editBudget('{{.ID}}')" style="margin-left:8px;">Edit</button>
|
<button class="btn btn-outline btn-sm" id="budget-btn-{{.ID}}"
|
||||||
|
onclick="editBudget('{{.ID}}')" style="margin-left:8px;">Edit</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}}')">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr><td colspan="4" class="text-center text-muted" style="padding:32px;">No categories yet. Add one above.</td></tr>
|
<tr>
|
||||||
|
<td colspan="4" class="text-center text-muted" style="padding:36px;">
|
||||||
|
No categories yet. Add one above.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -64,20 +71,17 @@
|
|||||||
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';
|
||||||
document.getElementById('budget-edit-' + id).style.display = 'inline-flex';
|
const edit = document.getElementById('budget-edit-' + id);
|
||||||
document.getElementById('budget-edit-' + id).style.gap = '6px';
|
edit.style.display = 'inline-flex';
|
||||||
document.getElementById('budget-edit-' + id).style.alignItems = 'center';
|
|
||||||
document.getElementById('budget-input-' + id).focus();
|
document.getElementById('budget-input-' + id).focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelBudget(id) {
|
function cancelBudget(id) {
|
||||||
document.getElementById('budget-display-' + id).style.display = 'inline';
|
document.getElementById('budget-display-' + id).style.display = 'inline';
|
||||||
document.getElementById('budget-btn-' + id).style.display = 'inline-block';
|
document.getElementById('budget-btn-' + id).style.display = 'inline-flex';
|
||||||
document.getElementById('budget-edit-' + id).style.display = 'none';
|
document.getElementById('budget-edit-' + id).style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveBudget(id) {
|
function saveBudget(id) {
|
||||||
const val = parseFloat(document.getElementById('budget-input-' + id).value.replace(',', '.'));
|
const val = parseFloat(document.getElementById('budget-input-' + id).value.replace(',', '.'));
|
||||||
if (isNaN(val) || val < 0) return;
|
if (isNaN(val) || val < 0) return;
|
||||||
const cents = Math.round(val * 100);
|
const cents = Math.round(val * 100);
|
||||||
fetch('/categories/' + id, {
|
fetch('/categories/' + id, {
|
||||||
@ -86,14 +90,15 @@ function saveBudget(id) {
|
|||||||
body: JSON.stringify({budget_cents: cents})
|
body: JSON.stringify({budget_cents: cents})
|
||||||
}).then(r => {
|
}).then(r => {
|
||||||
if (!r.ok) return;
|
if (!r.ok) return;
|
||||||
const display = document.getElementById('budget-display-' + id);
|
const d = document.getElementById('budget-display-' + id);
|
||||||
display.innerHTML = cents > 0 ? '€' + (cents / 100).toFixed(2) : '<span class="text-muted">No budget set</span>';
|
d.innerHTML = cents > 0
|
||||||
|
? '€' + (cents / 100).toLocaleString('pt-PT', {minimumFractionDigits:2})
|
||||||
|
: '<span class="text-muted">No budget</span>';
|
||||||
cancelBudget(id);
|
cancelBudget(id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function delCat(id) {
|
function delCat(id) {
|
||||||
if (!confirm('Delete this category? Existing transactions will keep their category label.')) return;
|
if (!confirm('Delete this category? Transactions keep their label.')) 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();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,48 +1,56 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
{{$d := .}}
|
{{$d := .}}
|
||||||
{{$change := sub $d.ThisMonth.TotalCents $d.LastMonth.TotalCents}}
|
{{$change := sub $d.ThisMonth.TotalCents $d.LastMonth.TotalCents}}
|
||||||
<h1 style="margin-bottom:24px;">Dashboard</h1>
|
|
||||||
|
|
||||||
<!-- Summary cards -->
|
<div style="display:flex; align-items:baseline; justify-content:space-between; margin-bottom:24px; flex-wrap:wrap; gap:8px;">
|
||||||
<div class="grid" style="grid-template-columns:repeat(auto-fit,minmax(200px,1fr));">
|
<h1>Dashboard</h1>
|
||||||
|
<span class="text-muted">{{if $d.Email}}{{$d.Email}}{{end}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- KPI cards -->
|
||||||
|
<div class="grid">
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>This Month (Net)</h2>
|
<h2>Net This Month</h2>
|
||||||
<div class="value {{if lt $d.ThisMonth.TotalCents 0}}negative{{else}}positive{{end}} animate-counter"
|
<div class="value {{if lt $d.ThisMonth.TotalCents 0}}negative{{else}}positive{{end}} animate-counter"
|
||||||
data-target="{{$d.ThisMonth.TotalCents}}" data-prefix="€">€0</div>
|
data-target="{{$d.ThisMonth.TotalCents}}" data-prefix="€">€0.00</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Income</h2>
|
<h2>Income</h2>
|
||||||
<div class="value positive animate-counter"
|
<div class="value positive animate-counter"
|
||||||
data-target="{{$d.ThisMonthIncome}}" data-prefix="€">€0</div>
|
data-target="{{$d.ThisMonthIncome}}" data-prefix="€">€0.00</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Expenses</h2>
|
<h2>Expenses</h2>
|
||||||
<div class="value negative animate-counter"
|
<div class="value negative animate-counter"
|
||||||
data-target="{{$d.ThisMonthExpense}}" data-prefix="€">€0</div>
|
data-target="{{$d.ThisMonthExpense}}" data-prefix="€">€0.00</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>vs Last Month</h2>
|
<h2>vs Last Month</h2>
|
||||||
<div class="value {{if lt $change 0}}negative{{else}}positive{{end}} animate-counter"
|
<div class="value {{if lt $change 0}}negative{{else}}positive{{end}} animate-counter"
|
||||||
data-target="{{$change}}" data-prefix="€">€0</div>
|
data-target="{{$change}}" data-prefix="€">€0.00</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Charts row -->
|
<!-- Charts -->
|
||||||
<div class="grid" style="grid-template-columns:1fr 1fr;">
|
<div class="grid-2">
|
||||||
<div class="card animate-on-scroll">
|
<div class="card animate-on-scroll">
|
||||||
<h2>Spending by Category (This Month)</h2>
|
<h2>Spending by Category — This Month</h2>
|
||||||
{{if $d.ThisMonth.ByCategory}}
|
{{if $d.ThisMonth.ByCategory}}
|
||||||
<canvas id="thisMonthChart" height="220"></canvas>
|
<div style="position:relative; padding-top:8px;">
|
||||||
|
<canvas id="thisMonthChart" height="220"></canvas>
|
||||||
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="empty-state" style="padding:32px;">No spending data this month.</div>
|
<div class="empty-state" style="padding:32px;">No spending data this month.</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="card animate-on-scroll">
|
<div class="card animate-on-scroll">
|
||||||
<h2>Balance Trend (90 days)</h2>
|
<h2>Balance Trend — 90 Days</h2>
|
||||||
{{if $d.BalanceTrend}}
|
{{if $d.BalanceTrend}}
|
||||||
<canvas id="balanceChart" height="220"></canvas>
|
<div style="position:relative; padding-top:8px;">
|
||||||
|
<canvas id="balanceChart" height="220"></canvas>
|
||||||
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="empty-state" style="padding:32px;">No transactions yet. <a href="/import">Import some!</a></div>
|
<div class="empty-state" style="padding:32px;">No transactions yet. <a href="/import" style="color:var(--accent);">Import some!</a></div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -50,36 +58,42 @@
|
|||||||
<!-- Budget progress -->
|
<!-- Budget progress -->
|
||||||
{{if $d.CategoryBudgets}}
|
{{if $d.CategoryBudgets}}
|
||||||
<div class="card animate-on-scroll">
|
<div class="card animate-on-scroll">
|
||||||
<h2 style="margin-bottom:16px;">Budget vs Actual (This Month)</h2>
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:18px;">
|
||||||
<div style="display:flex; flex-direction:column; gap:14px;">
|
<h2>Budget vs Actual — This Month</h2>
|
||||||
|
<a href="/categories" class="btn btn-outline btn-sm">Manage budgets</a>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:16px;">
|
||||||
{{range $cat, $budget := $d.CategoryBudgets}}
|
{{range $cat, $budget := $d.CategoryBudgets}}
|
||||||
{{$spent := index $d.ThisMonth.ByCategory $cat}}
|
{{$spent := index $d.ThisMonth.ByCategory $cat}}
|
||||||
{{$color := index $d.CategoryColors $cat}}
|
|
||||||
{{$spentAbs := centsAbs $spent}}
|
{{$spentAbs := centsAbs $spent}}
|
||||||
|
{{$color := index $d.CategoryColors $cat}}
|
||||||
|
{{$over := isOver $spentAbs $budget}}
|
||||||
|
{{$pct := clampPct $spentAbs $budget}}
|
||||||
<div>
|
<div>
|
||||||
<div style="display:flex; justify-content:space-between; align-items:baseline; margin-bottom:5px;">
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:7px;">
|
||||||
<span style="font-size:14px; font-weight:500; display:flex; align-items:center; gap:6px;">
|
<span style="font-size:13.5px; font-weight:500; display:flex; align-items:center; gap:7px;">
|
||||||
{{if $color}}<span style="width:9px;height:9px;border-radius:50%;background:{{$color}};display:inline-block;"></span>{{end}}
|
{{if $color}}<span style="width:9px;height:9px;border-radius:50%;background:{{$color}};display:inline-block;box-shadow:0 0 6px {{$color}}66;"></span>{{end}}
|
||||||
{{$cat}}
|
{{$cat}}
|
||||||
</span>
|
</span>
|
||||||
<span style="font-size:13px; color:#666;">
|
<span style="font-size:12.5px; {{if $over}}color:var(--red);font-weight:600;{{else}}color:var(--text2);{{end}}">
|
||||||
€{{cents $spentAbs}} <span style="color:#aaa;">/ €{{cents $budget}}</span>
|
€{{cents $spentAbs}} / €{{cents $budget}}
|
||||||
|
{{if $over}} ⚠ over budget{{end}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="background:#f0f0f0; border-radius:6px; height:8px; overflow:hidden;">
|
<div style="background:var(--bg3); border-radius:8px; height:7px; overflow:hidden;">
|
||||||
<div style="height:100%; border-radius:6px; width:{{clampPct $spentAbs $budget}}%; transition:width 0.8s ease;
|
<div style="height:100%; border-radius:8px; width:{{$pct}}%; transition:width 1s ease;
|
||||||
background:{{if isOver $spentAbs $budget}}#f44336{{else if $color}}{{$color}}{{else}}#3949ab{{end}};"></div>
|
background:{{if $over}}var(--red){{else if $color}}{{$color}}{{else}}var(--accent){{end}};
|
||||||
|
box-shadow:{{if $over}}0 0 8px var(--red){{else if $color}}0 0 6px {{$color}}88{{else}}0 0 6px var(--accent-glow){{end}};"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted" style="margin-top:12px;">Set budgets in <a href="/categories">Categories</a>.</p>
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<!-- Recent transactions -->
|
<!-- Recent transactions -->
|
||||||
<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:12px;">
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:14px;">
|
||||||
<h2>Recent Transactions</h2>
|
<h2>Recent Transactions</h2>
|
||||||
<a href="/transactions" class="btn btn-outline btn-sm">View all</a>
|
<a href="/transactions" class="btn btn-outline btn-sm">View all</a>
|
||||||
</div>
|
</div>
|
||||||
@ -97,23 +111,27 @@
|
|||||||
{{range $d.RecentTxns}}
|
{{range $d.RecentTxns}}
|
||||||
{{$color := index $d.CategoryColors .Category}}
|
{{$color := index $d.CategoryColors .Category}}
|
||||||
<tr>
|
<tr>
|
||||||
<td style="white-space:nowrap;">{{dateShort .Date}}</td>
|
<td style="white-space:nowrap; color:var(--text2);">{{dateShort .Date}}</td>
|
||||||
<td style="max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{{.Description}}</td>
|
<td style="max-width:260px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{{.Description}}</td>
|
||||||
<td>
|
<td>
|
||||||
<span style="display:inline-flex; align-items:center; gap:5px; padding:2px 10px; border-radius:12px; font-size:12px; font-weight:500;
|
<span class="badge" style="
|
||||||
background:{{if $color}}{{$color}}22{{else}}#e0e0e0{{end}};
|
background:{{if $color}}{{$color}}18{{else}}var(--bg3){{end}};
|
||||||
color:{{if $color}}{{$color}}{{else}}#555{{end}};
|
color:{{if $color}}{{$color}}{{else}}var(--text2){{end}};
|
||||||
border:1px solid {{if $color}}{{$color}}44{{else}}#ccc{{end}};">
|
border:1px solid {{if $color}}{{$color}}33{{else}}var(--border2){{end}};">
|
||||||
{{if $color}}<span style="width:7px;height:7px;border-radius:50%;background:{{$color}};"></span>{{end}}
|
{{if $color}}<span class="category-dot" style="background:{{$color}};box-shadow:0 0 4px {{$color}}88;"></span>{{end}}
|
||||||
{{.Category}}
|
{{.Category}}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="cents {{if lt .AmountCents 0}}negative{{else}}positive{{end}}" style="white-space:nowrap;">
|
<td class="cents {{if lt .AmountCents 0}}negative{{else}}positive{{end}}" style="font-weight:600; white-space:nowrap;">
|
||||||
{{if lt .AmountCents 0}}−{{else}}+{{end}}€{{cents (centsAbs .AmountCents)}}
|
{{if lt .AmountCents 0}}−{{else}}+{{end}}€{{cents (centsAbs .AmountCents)}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr><td colspan="4" class="text-center text-muted" style="padding:32px;">No transactions yet. <a href="/import">Import some!</a></td></tr>
|
<tr>
|
||||||
|
<td colspan="4" class="text-center text-muted" style="padding:36px;">
|
||||||
|
No transactions yet. <a href="/import" style="color:var(--accent);">Import some!</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -121,69 +139,75 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const barColors = ['#FF6384','#36A2EB','#FFCE56','#4BC0C0','#9966FF','#FF9F40','#C9CBCF','#00E676','#651FFF','#FF6F00','#E91E63','#607D8B','#3F51B5','#9E9E9E'];
|
|
||||||
const catColors = { {{range $k,$v := $d.CategoryColors}}"{{$k}}":"{{$v}}",{{end}} };
|
const catColors = { {{range $k,$v := $d.CategoryColors}}"{{$k}}":"{{$v}}",{{end}} };
|
||||||
|
const isDark = () => document.documentElement.getAttribute('data-theme') === 'dark';
|
||||||
|
|
||||||
|
function gridColor() { return isDark() ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.06)'; }
|
||||||
|
function textColor() { return isDark() ? '#5c6585' : '#8a92b0'; }
|
||||||
|
|
||||||
{{if $d.ThisMonth.ByCategory}}
|
{{if $d.ThisMonth.ByCategory}}
|
||||||
const thisMonthLabels = {{jsonKeys $d.ThisMonth.ByCategory}};
|
const catLabels = {{jsonKeys $d.ThisMonth.ByCategory}};
|
||||||
const thisMonthData = {{jsonVals $d.ThisMonth.ByCategory}};
|
const catData = {{jsonVals $d.ThisMonth.ByCategory}};
|
||||||
const resolvedColors = thisMonthLabels.map((k,i) => catColors[k] || barColors[i % barColors.length]);
|
const resolvedColors = catLabels.map(k => catColors[k] || '#6979f8');
|
||||||
|
|
||||||
new Chart(document.getElementById('thisMonthChart'), {
|
new Chart(document.getElementById('thisMonthChart'), {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: {
|
data: {
|
||||||
labels: thisMonthLabels,
|
labels: catLabels,
|
||||||
datasets: [{
|
datasets: [{
|
||||||
label: 'Amount (€)',
|
data: catData.map(v => Math.abs(v) / 100),
|
||||||
data: thisMonthData.map(v => Math.abs(v) / 100),
|
backgroundColor: resolvedColors.map(c => c + '99'),
|
||||||
backgroundColor: resolvedColors.map(c => c + 'cc'),
|
|
||||||
borderColor: resolvedColors,
|
borderColor: resolvedColors,
|
||||||
borderWidth: 1,
|
borderWidth: 1.5,
|
||||||
borderRadius: 5,
|
borderRadius: 6,
|
||||||
borderSkipped: false,
|
borderSkipped: false,
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
animation: { duration: 1000, easing: 'easeOutQuart' },
|
animation: { duration: 900, easing: 'easeOutQuart' },
|
||||||
plugins: { legend: { display: false } },
|
plugins: { legend: { display: false } },
|
||||||
scales: {
|
scales: {
|
||||||
y: { beginAtZero: true, grid: { color: 'rgba(0,0,0,0.05)' }, ticks: { callback: v => '€' + v } },
|
y: { beginAtZero: true, grid: { color: gridColor() }, ticks: { color: textColor(), callback: v => '€' + v } },
|
||||||
x: { grid: { display: false } }
|
x: { grid: { display: false }, ticks: { color: textColor() } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if $d.BalanceTrend}}
|
{{if $d.BalanceTrend}}
|
||||||
const grad = document.createElement('canvas').getContext('2d').createLinearGradient(0,0,0,300);
|
const ctx = document.getElementById('balanceChart').getContext('2d');
|
||||||
grad.addColorStop(0, 'rgba(57,73,171,0.35)');
|
const grad = ctx.createLinearGradient(0, 0, 0, 260);
|
||||||
grad.addColorStop(0.6, 'rgba(57,73,171,0.1)');
|
grad.addColorStop(0, 'rgba(105,121,248,0.30)');
|
||||||
grad.addColorStop(1, 'rgba(57,73,171,0.01)');
|
grad.addColorStop(0.6, 'rgba(105,121,248,0.08)');
|
||||||
new Chart(document.getElementById('balanceChart'), {
|
grad.addColorStop(1, 'rgba(105,121,248,0.00)');
|
||||||
|
|
||||||
|
new Chart(ctx, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: {
|
data: {
|
||||||
labels: [{{range $d.BalanceTrend}}"{{dateShort .Date}}",{{end}}],
|
labels: [{{range $d.BalanceTrend}}"{{dateShort .Date}}",{{end}}],
|
||||||
datasets: [{
|
datasets: [{
|
||||||
label: 'Balance (€)',
|
label: 'Balance (€)',
|
||||||
data: [{{range $d.BalanceTrend}}{{div .Cents 100}},{{end}}],
|
data: [{{range $d.BalanceTrend}}{{div .Cents 100}},{{end}}],
|
||||||
borderColor: '#3949ab',
|
borderColor: '#6979f8',
|
||||||
backgroundColor: grad,
|
backgroundColor: grad,
|
||||||
fill: true,
|
fill: true,
|
||||||
tension: 0.4,
|
tension: 0.42,
|
||||||
pointRadius: 0,
|
pointRadius: 0,
|
||||||
pointHoverRadius: 5,
|
pointHoverRadius: 5,
|
||||||
pointBackgroundColor: '#fff',
|
pointBackgroundColor: '#fff',
|
||||||
pointBorderColor: '#3949ab',
|
pointBorderColor: '#6979f8',
|
||||||
pointBorderWidth: 2,
|
pointBorderWidth: 2,
|
||||||
|
borderWidth: 2.5,
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
animation: { duration: 1200, easing: 'easeOutQuart' },
|
animation: { duration: 1100, easing: 'easeOutQuart' },
|
||||||
plugins: { legend: { display: false } },
|
plugins: { legend: { display: false } },
|
||||||
scales: {
|
scales: {
|
||||||
y: { beginAtZero: false, grid: { color: 'rgba(0,0,0,0.05)' }, ticks: { callback: v => '€' + v } },
|
y: { beginAtZero: false, grid: { color: gridColor() }, ticks: { color: textColor(), callback: v => '€' + v } },
|
||||||
x: { grid: { display: false }, ticks: { maxTicksLimit: 8 } }
|
x: { grid: { display: false }, ticks: { color: textColor(), maxTicksLimit: 7 } }
|
||||||
},
|
},
|
||||||
interaction: { intersect: false, mode: 'index' }
|
interaction: { intersect: false, mode: 'index' }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,31 +1,54 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
{{$d := .}}
|
{{$d := .}}
|
||||||
<h1 style="margin-bottom: 24px;">Import Transactions</h1>
|
<h1 style="margin-bottom:24px;">Import</h1>
|
||||||
|
|
||||||
|
{{if $d.Error}}
|
||||||
|
<div class="card" style="border-color:var(--red); background:var(--red-dim);">
|
||||||
|
<span style="color:var(--red); font-size:13.5px;">⚠ {{$d.Error}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{if $d.Preview}}
|
{{if $d.Preview}}
|
||||||
<div class="card">
|
<!-- Preview table -->
|
||||||
<h2>Preview — {{$d.Preview.Total}} rows</h2>
|
<div class="card animate-on-scroll">
|
||||||
<form method="POST" action="/import/confirm" class="mt-16">
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px; flex-wrap:wrap; gap:8px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:15px; font-weight:700; color:var(--text); text-transform:none; letter-spacing:0;">Preview</h2>
|
||||||
|
<p class="text-muted" style="margin-top:3px;">{{$d.Preview.Total}} rows — review categories before confirming.</p>
|
||||||
|
</div>
|
||||||
|
<a href="/import" class="btn btn-outline btn-sm">← Back</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="/import/confirm">
|
||||||
<input type="hidden" name="account_id" value="{{$d.Preview.AccountID}}">
|
<input type="hidden" name="account_id" value="{{$d.Preview.AccountID}}">
|
||||||
<input type="hidden" name="format" value="{{$d.SelectedFormat}}">
|
<input type="hidden" name="format" value="{{$d.SelectedFormat}}">
|
||||||
<input type="hidden" name="raw_data" value="{{$d.RawData}}">
|
<input type="hidden" name="raw_data" value="{{$d.RawData}}">
|
||||||
|
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Date</th><th>Description</th><th class="text-right">Amount</th><th>Category</th></tr>
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th class="text-right">Amount</th>
|
||||||
|
<th>Category</th>
|
||||||
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range $i, $row := $d.Preview.Rows}}
|
{{range $i, $row := $d.Preview.Rows}}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{$row.Date}}</td>
|
<td style="white-space:nowrap; color:var(--text2);">{{$row.Date}}</td>
|
||||||
<td>{{$row.Description}}</td>
|
<td style="max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{{$row.Description}}</td>
|
||||||
<td class="cents {{if lt $row.AmountCents 0}}negative{{else}}positive{{end}}">
|
<td class="cents {{if lt $row.AmountCents 0}}negative{{else}}positive{{end}}" style="font-weight:600; white-space:nowrap;">
|
||||||
€{{cents $row.AmountCents}}
|
{{if lt $row.AmountCents 0}}−{{else}}+{{end}}€{{cents (centsAbs $row.AmountCents)}}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<select name="categories" class="cat-select" data-color="">
|
<select name="categories" class="cat-select" data-selected="{{$row.Category}}"
|
||||||
|
style="font-size:12.5px; padding:5px 8px; border:1.5px solid var(--border2);
|
||||||
|
border-radius:6px; background:var(--bg2); color:var(--text);
|
||||||
|
border-left-width:4px; cursor:pointer; min-width:130px;">
|
||||||
{{range $cat := $d.Categories}}
|
{{range $cat := $d.Categories}}
|
||||||
<option value="{{$cat}}" data-color="{{index $d.CategoryColors $cat}}" {{if eq $cat $row.Category}}selected{{end}}>{{$cat}}</option>
|
<option value="{{$cat}}" {{if eq $cat $row.Category}}selected{{end}}>{{$cat}}</option>
|
||||||
{{end}}
|
{{end}}
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
@ -34,83 +57,82 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap" style="gap: 8px; margin-top: 16px;">
|
|
||||||
<button type="submit" class="btn btn-primary">Confirm Import</button>
|
<div style="display:flex; gap:10px; margin-top:18px; flex-wrap:wrap;">
|
||||||
|
<button type="submit" class="btn btn-primary">✓ Confirm Import</button>
|
||||||
<a href="/import" class="btn btn-outline">Cancel</a>
|
<a href="/import" class="btn btn-outline">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
|
||||||
<div class="card">
|
|
||||||
<form method="POST" action="/import/preview" enctype="multipart/form-data">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Account</label>
|
|
||||||
<select name="account_id" required>
|
|
||||||
<option value="">Select account...</option>
|
|
||||||
{{range $d.Accounts}}
|
|
||||||
<option value="{{.ID}}">{{.Name}} ({{.Type}})</option>
|
|
||||||
{{end}}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Bank / Format</label>
|
|
||||||
<select name="format" required>
|
|
||||||
<option value="cgd">Caixa Geral de Depósitos (CGD)</option>
|
|
||||||
<option value="traderepublic">Trade Republic (Card)</option>
|
|
||||||
<option value="generic">Generic CSV</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>CSV File</label>
|
|
||||||
<input type="file" name="file" accept=".csv,.txt" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">Preview</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<hr style="margin: 24px 0;">
|
|
||||||
|
|
||||||
<h2 class="mb-16">Import Securities Trades</h2>
|
|
||||||
<form method="POST" action="/import/securities" enctype="multipart/form-data">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Trade Republic Securities CSV</label>
|
|
||||||
<input type="file" name="file" accept=".csv,.txt" required>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-primary">Import Securities</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{if $d.Error}}
|
|
||||||
<div class="card error">{{$d.Error}}</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
hr { border: none; border-top: 1px solid #e0e0e0; }
|
|
||||||
.cat-select {
|
|
||||||
padding: 4px 8px; border: 1px solid #ddd; border-radius: 6px;
|
|
||||||
font-size: 13px; background: #fff; cursor: pointer; min-width: 120px;
|
|
||||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
|
||||||
border-left: 4px solid #ddd;
|
|
||||||
}
|
|
||||||
.cat-select:focus {
|
|
||||||
outline: none; border-color: #3949ab; box-shadow: 0 0 0 3px rgba(57,73,171,0.1);
|
|
||||||
}
|
|
||||||
.cents.positive { color: #4caf50; font-weight: 600; }
|
|
||||||
.cents.negative { color: #f44336; }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.querySelectorAll('.cat-select').forEach(function(sel) {
|
const catColors = { {{range $k,$v := $d.CategoryColors}}"{{$k}}":"{{$v}}",{{end}} };
|
||||||
function updateColor() {
|
document.querySelectorAll('.cat-select').forEach(sel => {
|
||||||
var opt = sel.options[sel.selectedIndex];
|
function paint() {
|
||||||
var color = opt.getAttribute('data-color') || '#ddd';
|
const color = catColors[sel.value] || '';
|
||||||
sel.style.borderLeftColor = color;
|
sel.style.borderLeftColor = color || 'var(--border2)';
|
||||||
}
|
}
|
||||||
sel.addEventListener('change', updateColor);
|
sel.addEventListener('change', paint);
|
||||||
updateColor();
|
paint();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{{else}}
|
||||||
|
<!-- Upload forms -->
|
||||||
|
<div class="grid-2" style="align-items:start;">
|
||||||
|
<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;">
|
||||||
|
Bank Transactions
|
||||||
|
</h2>
|
||||||
|
<form method="POST" action="/import/preview" enctype="multipart/form-data">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Account</label>
|
||||||
|
<select name="account_id" required>
|
||||||
|
<option value="">Select account…</option>
|
||||||
|
{{range $d.Accounts}}
|
||||||
|
<option value="{{.ID}}">{{.Name}} ({{.Type}})</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Bank / Format</label>
|
||||||
|
<select name="format">
|
||||||
|
<option value="cgd">Caixa Geral de Depósitos (CGD)</option>
|
||||||
|
<option value="traderepublic">Trade Republic Card</option>
|
||||||
|
<option value="generic">Generic CSV</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>CSV File</label>
|
||||||
|
<input type="file" name="file" accept=".csv,.txt" required
|
||||||
|
style="padding:10px; cursor:pointer; font-size:13px;">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" style="width:100%;">Preview Import</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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;">
|
||||||
|
Securities Trades
|
||||||
|
</h2>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
<form method="POST" action="/import/securities" enctype="multipart/form-data">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Trade Republic Securities CSV</label>
|
||||||
|
<input type="file" name="file" accept=".csv,.txt" required
|
||||||
|
style="padding:10px; cursor:pointer; font-size:13px;">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" style="width:100%;">Import Trades</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<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;">
|
||||||
|
After importing, visit <a href="/portfolio" style="color:var(--accent);">Portfolio</a> to see live prices and P&L.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@ -1,69 +1,84 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
{{$d := .}}
|
{{$d := .}}
|
||||||
<h1 style="margin-bottom: 24px;">Portfolio</h1>
|
<h1 style="margin-bottom:24px;">Portfolio</h1>
|
||||||
|
|
||||||
{{if $d.Holdings}}
|
{{if $d.Holdings}}
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="card value-card">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Total Value</h2>
|
<h2>Total Value</h2>
|
||||||
<div class="value {{if ge $d.TotalPCLCents 0}}positive{{else}}negative{{end}}">
|
<div class="value {{if ge $d.TotalPCLCents 0}}positive{{else}}negative{{end}} animate-counter"
|
||||||
€{{cents $d.TotalValueCents}}
|
data-target="{{$d.TotalValueCents}}" data-prefix="€">€0.00</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card value-card">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Total Cost</h2>
|
<h2>Total Cost</h2>
|
||||||
<div class="value">€{{cents $d.TotalCostCents}}</div>
|
<div class="value animate-counter" data-target="{{$d.TotalCostCents}}" data-prefix="€"
|
||||||
|
style="color:var(--text);">€0.00</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card value-card">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Unrealized P&L</h2>
|
<h2>Unrealized P&L</h2>
|
||||||
<div class="value {{if ge $d.TotalPCLCents 0}}positive{{else}}negative{{end}}">
|
<div class="value {{if ge $d.TotalPCLCents 0}}positive{{else}}negative{{end}} animate-counter"
|
||||||
{{pctSign $d.TotalPCLPct}}€{{cents $d.TotalPCLCents}}
|
data-target="{{$d.TotalPCLCents}}" data-prefix="€">€0.00</div>
|
||||||
({{pctSign $d.TotalPCLPct}}{{printf "%.2f" $d.TotalPCLPct}}%)
|
<p style="font-size:13px; margin-top:6px; {{if ge $d.TotalPCLPct 0}}color:var(--green){{else}}color:var(--red){{end}};">
|
||||||
|
{{pctSign $d.TotalPCLPct}}{{printf "%.2f" $d.TotalPCLPct}}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2" style="align-items:start;">
|
||||||
|
<!-- Allocation donut -->
|
||||||
|
<div class="card animate-on-scroll">
|
||||||
|
<h2 style="margin-bottom:16px;">Allocation</h2>
|
||||||
|
<div id="allocation3d" style="width:100%; height:380px; position:relative;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Holdings table -->
|
||||||
|
<div class="card animate-on-scroll">
|
||||||
|
<h2 style="margin-bottom:14px;">Holdings</h2>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Asset</th>
|
||||||
|
<th class="text-right">Shares</th>
|
||||||
|
<th class="text-right">Avg Cost</th>
|
||||||
|
<th class="text-right">Price</th>
|
||||||
|
<th class="text-right">Value</th>
|
||||||
|
<th class="text-right">P&L</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range $i, $h := $d.Holdings}}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div style="font-weight:600; font-size:13.5px;">{{.Name}}</div>
|
||||||
|
<div class="text-muted" style="font-size:11.5px; margin-top:1px;">{{.ISIN}}</div>
|
||||||
|
</td>
|
||||||
|
<td class="cents" style="font-size:13px; color:var(--text2);">{{printf "%.4f" .SharesOwned}}</td>
|
||||||
|
<td class="cents" style="font-size:13px;">€{{cents .AvgEntryCents}}</td>
|
||||||
|
<td class="cents" style="font-size:13px;">€{{cents .CurrentPriceCents}}</td>
|
||||||
|
<td class="cents" style="font-weight:600;">€{{cents .CurrentValueCents}}</td>
|
||||||
|
<td class="cents">
|
||||||
|
<div class="{{if ge .UnrealizedPCLCents 0}}positive{{else}}negative{{end}}" style="font-weight:600; font-size:13px;">
|
||||||
|
{{pctSign .UnrealizedPCLPct}}{{printf "%.2f" .UnrealizedPCLPct}}%
|
||||||
|
</div>
|
||||||
|
<div class="{{if ge .UnrealizedPCLCents 0}}positive{{else}}negative{{end}}" style="font-size:11.5px; opacity:0.8;">
|
||||||
|
{{if ge .UnrealizedPCLCents 0}}+{{else}}−{{end}}€{{cents (centsAbs .UnrealizedPCLCents)}}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<a href="/import" class="btn btn-outline btn-sm">Import CSV</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>Allocation</h2>
|
|
||||||
<div style="max-width: 500px; margin: 0 auto; position: relative;">
|
|
||||||
<canvas id="allocationChart2d" height="300" style="display:none;"></canvas>
|
|
||||||
<div id="allocation3d" style="width:100%; height:400px;"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card table-wrap">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Stock</th>
|
|
||||||
<th>Shares</th>
|
|
||||||
<th class="text-right">Avg Entry</th>
|
|
||||||
<th class="text-right">Current Price</th>
|
|
||||||
<th class="text-right">Value</th>
|
|
||||||
<th class="text-right">P&L</th>
|
|
||||||
<th class="text-right">Return</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{{range $i, $h := $d.Holdings}}
|
|
||||||
<tr>
|
|
||||||
<td><strong>{{.Name}}</strong><br><span class="text-muted">{{.ISIN}}</span></td>
|
|
||||||
<td>{{printf "%.4f" .SharesOwned}}</td>
|
|
||||||
<td class="cents">€{{cents .AvgEntryCents}}</td>
|
|
||||||
<td class="cents">€{{cents .CurrentPriceCents}}</td>
|
|
||||||
<td class="cents">€{{cents .CurrentValueCents}}</td>
|
|
||||||
<td class="cents {{if ge .UnrealizedPCLCents 0}}positive{{else}}negative{{end}}">
|
|
||||||
€{{cents .UnrealizedPCLCents}}
|
|
||||||
</td>
|
|
||||||
<td class="cents {{if ge .UnrealizedPCLPct 0}}positive{{else}}negative{{end}}">
|
|
||||||
{{pctSign .UnrealizedPCLPct}}{{printf "%.2f" .UnrealizedPCLPct}}%
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
{
|
{
|
||||||
"imports": {
|
"imports": {
|
||||||
@ -76,191 +91,133 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||||
|
|
||||||
const colors = ['#FF6384','#36A2EB','#FFCE56','#4BC0C0','#9966FF','#FF9F40','#C9CBCF','#00E676','#651FFF','#FF6F00','#E91E63','#607D8B','#3F51B5','#9E9E9E'];
|
const palette = [
|
||||||
|
'#6979f8','#f87171','#fbbf24','#34d399','#a78bfa',
|
||||||
|
'#f472b6','#38bdf8','#fb923c','#4ade80','#e879f9',
|
||||||
|
];
|
||||||
|
|
||||||
const holdings = [
|
const holdings = [
|
||||||
{{range $d.Holdings}}
|
{{range $d.Holdings}}{ name: "{{.Name}}", value: {{.CurrentValueCents}} },{{end}}
|
||||||
{ name: "{{.Name}}", value: {{.CurrentValueCents}} },
|
|
||||||
{{end}}
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const total = holdings.reduce((s, h) => s + h.value, 0);
|
const total = holdings.reduce((s, h) => s + h.value, 0);
|
||||||
if (total > 0) {
|
if (total <= 0) return;
|
||||||
const container = document.getElementById('allocation3d');
|
|
||||||
const w = container.clientWidth;
|
|
||||||
const h = 400;
|
|
||||||
|
|
||||||
const scene = new THREE.Scene();
|
const container = document.getElementById('allocation3d');
|
||||||
const camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 100);
|
const W = container.clientWidth, H = 380;
|
||||||
camera.position.set(0, 4, 8);
|
|
||||||
camera.lookAt(0, 0, 0);
|
|
||||||
|
|
||||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
const scene = new THREE.Scene();
|
||||||
renderer.setSize(w, h);
|
const camera = new THREE.PerspectiveCamera(42, W / H, 0.1, 100);
|
||||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
camera.position.set(0, 5, 9);
|
||||||
renderer.shadowMap.enabled = true;
|
camera.lookAt(0, 0, 0);
|
||||||
container.appendChild(renderer.domElement);
|
|
||||||
|
|
||||||
const controls = new OrbitControls(camera, renderer.domElement);
|
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||||
controls.enableDamping = true;
|
renderer.setSize(W, H);
|
||||||
controls.dampingFactor = 0.08;
|
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||||
controls.autoRotate = true;
|
renderer.shadowMap.enabled = true;
|
||||||
controls.autoRotateSpeed = 1.5;
|
container.appendChild(renderer.domElement);
|
||||||
controls.minDistance = 4;
|
|
||||||
controls.maxDistance = 15;
|
|
||||||
controls.target.set(0, 0, 0);
|
|
||||||
|
|
||||||
const group = new THREE.Group();
|
const controls = new OrbitControls(camera, renderer.domElement);
|
||||||
|
controls.enableDamping = true;
|
||||||
|
controls.dampingFactor = 0.07;
|
||||||
|
controls.autoRotate = true;
|
||||||
|
controls.autoRotateSpeed = 1.2;
|
||||||
|
controls.minDistance = 5;
|
||||||
|
controls.maxDistance = 16;
|
||||||
|
|
||||||
const innerRadius = 1.2;
|
// tooltip
|
||||||
const outerRadius = 2.8;
|
const tip = Object.assign(document.createElement('div'), {
|
||||||
const depth = 0.6;
|
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);'
|
||||||
let angle = 0;
|
});
|
||||||
|
container.appendChild(tip);
|
||||||
|
|
||||||
const tooltip = document.createElement('div');
|
const group = new THREE.Group();
|
||||||
tooltip.style.cssText = 'position:absolute;background:rgba(0,0,0,0.8);color:#fff;padding:6px 12px;border-radius:6px;font-size:13px;pointer-events:none;opacity:0;transition:opacity 0.2s;z-index:10;';
|
const IR = 1.3, OR = 3.0, D = 0.55;
|
||||||
container.appendChild(tooltip);
|
let angle = 0, hovered = null;
|
||||||
|
|
||||||
const raycaster = new THREE.Raycaster();
|
const raycaster = new THREE.Raycaster();
|
||||||
const pointer = new THREE.Vector2();
|
const pointer = new THREE.Vector2();
|
||||||
let hovered = null;
|
const meshes = [];
|
||||||
|
|
||||||
holdings.forEach((h, i) => {
|
holdings.forEach((h, i) => {
|
||||||
const arcAngle = (h.value / total) * Math.PI * 2;
|
const arc = (h.value / total) * Math.PI * 2;
|
||||||
const color = colors[i % colors.length];
|
const color = palette[i % palette.length];
|
||||||
|
const mid = angle + arc / 2;
|
||||||
|
|
||||||
const shape = new THREE.Shape();
|
const shape = new THREE.Shape();
|
||||||
const segments = 32;
|
shape.moveTo(IR, 0);
|
||||||
const startAngle = angle;
|
shape.absarc(0, 0, OR, angle, angle + arc, false);
|
||||||
const endAngle = angle + arcAngle;
|
shape.absarc(0, 0, IR, angle + arc, angle, true);
|
||||||
|
shape.closePath();
|
||||||
|
|
||||||
shape.moveTo(innerRadius, 0);
|
const geo = new THREE.ExtrudeGeometry(shape, {
|
||||||
shape.absarc(0, 0, outerRadius, startAngle, endAngle, false);
|
depth: D, bevelEnabled: true, bevelThickness: 0.07, bevelSize: 0.04, bevelSegments: 6,
|
||||||
shape.absarc(0, 0, innerRadius, endAngle, startAngle, true);
|
|
||||||
shape.closePath();
|
|
||||||
|
|
||||||
const extrudeSettings = {
|
|
||||||
depth: depth,
|
|
||||||
bevelEnabled: true,
|
|
||||||
bevelThickness: 0.08,
|
|
||||||
bevelSize: 0.04,
|
|
||||||
bevelSegments: 8,
|
|
||||||
};
|
|
||||||
|
|
||||||
const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings);
|
|
||||||
const material = new THREE.MeshPhysicalMaterial({
|
|
||||||
color: color,
|
|
||||||
metalness: 0.2,
|
|
||||||
roughness: 0.3,
|
|
||||||
clearcoat: 0.1,
|
|
||||||
side: THREE.DoubleSide,
|
|
||||||
});
|
|
||||||
const mesh = new THREE.Mesh(geometry, material);
|
|
||||||
mesh.rotation.x = -Math.PI / 2;
|
|
||||||
mesh.position.y = -depth / 2;
|
|
||||||
mesh.userData = { holding: h };
|
|
||||||
mesh.castShadow = true;
|
|
||||||
mesh.receiveShadow = true;
|
|
||||||
group.add(mesh);
|
|
||||||
|
|
||||||
const edgeGeo = new THREE.EdgesGeometry(geometry);
|
|
||||||
const edgeMat = new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.15 });
|
|
||||||
const edge = new THREE.LineSegments(edgeGeo, edgeMat);
|
|
||||||
edge.rotation.x = -Math.PI / 2;
|
|
||||||
edge.position.y = -depth / 2;
|
|
||||||
group.add(edge);
|
|
||||||
|
|
||||||
angle = endAngle;
|
|
||||||
});
|
});
|
||||||
|
const mat = new THREE.MeshPhysicalMaterial({
|
||||||
scene.add(group);
|
color, metalness: 0.15, roughness: 0.35, clearcoat: 0.15, side: THREE.DoubleSide,
|
||||||
|
|
||||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
|
||||||
scene.add(ambientLight);
|
|
||||||
|
|
||||||
const dirLight = new THREE.DirectionalLight(0xffffff, 1.2);
|
|
||||||
dirLight.position.set(5, 10, 7);
|
|
||||||
dirLight.castShadow = true;
|
|
||||||
scene.add(dirLight);
|
|
||||||
|
|
||||||
const backLight = new THREE.DirectionalLight(0xffffff, 0.4);
|
|
||||||
backLight.position.set(-5, 0, -5);
|
|
||||||
scene.add(backLight);
|
|
||||||
|
|
||||||
const floorGeo = new THREE.RingGeometry(1.5, 3.5, 64);
|
|
||||||
const floorMat = new THREE.MeshPhysicalMaterial({
|
|
||||||
color: 0x1a237e,
|
|
||||||
transparent: true,
|
|
||||||
opacity: 0.06,
|
|
||||||
side: THREE.DoubleSide,
|
|
||||||
roughness: 0.8,
|
|
||||||
metalness: 0.1,
|
|
||||||
});
|
});
|
||||||
const floor = new THREE.Mesh(floorGeo, floorMat);
|
const mesh = new THREE.Mesh(geo, mat);
|
||||||
floor.rotation.x = -Math.PI / 2;
|
mesh.rotation.x = -Math.PI / 2;
|
||||||
floor.position.y = -depth / 2 - 0.01;
|
mesh.position.y = -D / 2;
|
||||||
scene.add(floor);
|
mesh.userData = { holding: h, baseY: -D / 2, color, pct: (h.value / total * 100).toFixed(1) };
|
||||||
|
mesh.castShadow = true;
|
||||||
|
group.add(mesh);
|
||||||
|
meshes.push(mesh);
|
||||||
|
|
||||||
function onPointerMove(event) {
|
angle += arc;
|
||||||
const rect = renderer.domElement.getBoundingClientRect();
|
});
|
||||||
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
scene.add(group);
|
||||||
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
|
||||||
|
|
||||||
raycaster.setFromCamera(pointer, camera);
|
scene.add(Object.assign(new THREE.AmbientLight(0xffffff, 0.55)));
|
||||||
const intersects = raycaster.intersectObjects(group.children);
|
const dir = new THREE.DirectionalLight(0xffffff, 1.1);
|
||||||
|
dir.position.set(5, 10, 7); dir.castShadow = true; scene.add(dir);
|
||||||
|
const fill = new THREE.DirectionalLight(0x8b9ffc, 0.4);
|
||||||
|
fill.position.set(-5, 2, -5); scene.add(fill);
|
||||||
|
|
||||||
if (intersects.length > 0) {
|
renderer.domElement.addEventListener('pointermove', e => {
|
||||||
const hit = intersects[0].object;
|
const rect = renderer.domElement.getBoundingClientRect();
|
||||||
if (hit.userData.holding) {
|
pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
||||||
const h = hit.userData.holding;
|
pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
||||||
const pct = ((h.value / total) * 100).toFixed(1);
|
raycaster.setFromCamera(pointer, camera);
|
||||||
tooltip.textContent = `${h.name}: €${(h.value / 100).toFixed(2)} (${pct}%)`;
|
const hits = raycaster.intersectObjects(meshes);
|
||||||
tooltip.style.opacity = '1';
|
if (hits.length && hits[0].object.userData.holding) {
|
||||||
tooltip.style.left = (event.clientX - rect.left + 12) + 'px';
|
const m = hits[0].object;
|
||||||
tooltip.style.top = (event.clientY - rect.top - 10) + 'px';
|
const h = m.userData.holding;
|
||||||
|
tip.textContent = `${h.name}: €${(h.value/100).toLocaleString('pt-PT',{minimumFractionDigits:2})} (${m.userData.pct}%)`;
|
||||||
if (hovered !== hit) {
|
tip.style.opacity = '1';
|
||||||
if (hovered) {
|
tip.style.left = (e.clientX - rect.left + 14) + 'px';
|
||||||
hovered.material.opacity = 1;
|
tip.style.top = (e.clientY - rect.top - 10) + 'px';
|
||||||
hovered.material.needsUpdate = true;
|
if (hovered !== m) {
|
||||||
}
|
if (hovered) { hovered.position.y = hovered.userData.baseY; }
|
||||||
hit.material.opacity = 0.85;
|
m.position.y = m.userData.baseY + 0.25;
|
||||||
hit.material.needsUpdate = true;
|
hovered = m;
|
||||||
hovered = hit;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tooltip.style.opacity = '0';
|
|
||||||
if (hovered) {
|
|
||||||
hovered.material.opacity = 1;
|
|
||||||
hovered.material.needsUpdate = true;
|
|
||||||
hovered = null;
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
tip.style.opacity = '0';
|
||||||
|
if (hovered) { hovered.position.y = hovered.userData.baseY; hovered = null; }
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
renderer.domElement.addEventListener('pointermove', onPointerMove);
|
(function animate() {
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
controls.update();
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
})();
|
||||||
|
|
||||||
function animate() {
|
window.addEventListener('resize', () => {
|
||||||
requestAnimationFrame(animate);
|
const w2 = container.clientWidth;
|
||||||
controls.update();
|
camera.aspect = w2 / H;
|
||||||
renderer.render(scene, camera);
|
camera.updateProjectionMatrix();
|
||||||
}
|
renderer.setSize(w2, H);
|
||||||
animate();
|
});
|
||||||
|
|
||||||
function resize() {
|
|
||||||
const w2 = container.clientWidth;
|
|
||||||
camera.aspect = w2 / h;
|
|
||||||
camera.updateProjectionMatrix();
|
|
||||||
renderer.setSize(w2, h);
|
|
||||||
}
|
|
||||||
window.addEventListener('resize', resize);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="card empty-state">
|
<div class="card empty-state animate-on-scroll">
|
||||||
<h3>No trades imported yet</h3>
|
<div style="font-size:48px; margin-bottom:16px;">📈</div>
|
||||||
<p>Go to <a href="/import">Import</a> and upload your Trade Republic securities CSV.</p>
|
<h3>No trades yet</h3>
|
||||||
|
<p style="margin-bottom:20px;">Import your Trade Republic securities CSV to see your portfolio.</p>
|
||||||
|
<a href="/import" class="btn btn-primary">Import Trades</a>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@ -1,62 +1,90 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
{{$d := .}}
|
{{$d := .}}
|
||||||
<h1 style="margin-bottom: 24px;">Projections</h1>
|
<h1 style="margin-bottom:24px;">Projections</h1>
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="card">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Projected Annual Spend</h2>
|
<h2>Projected Annual Spend</h2>
|
||||||
<div class="value negative">€{{cents $d.AnnualTotal}}</div>
|
<div class="value negative animate-counter" data-target="{{$d.AnnualTotal}}" data-prefix="€">€0.00</div>
|
||||||
|
<p class="text-muted" style="margin-top:8px;">Based on 6-month average</p>
|
||||||
|
</div>
|
||||||
|
<div class="card value-card animate-on-scroll">
|
||||||
|
<h2>Projected Monthly Spend</h2>
|
||||||
|
{{$monthly := div $d.AnnualTotal 12}}
|
||||||
|
<div class="value negative animate-counter" data-target="{{$monthly}}" data-prefix="€">€0.00</div>
|
||||||
|
<p class="text-muted" style="margin-top:8px;">Average across categories</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card animate-on-scroll">
|
<div class="card animate-on-scroll">
|
||||||
<h2>Monthly Average Spend (Last 6 Months)</h2>
|
<h2 style="margin-bottom:12px;">Monthly Average by Category — Last 6 Months</h2>
|
||||||
<canvas id="projChart" height="250"></canvas>
|
<div style="padding-top:4px;">
|
||||||
|
<canvas id="projChart" height="260"></canvas>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card table-wrap">
|
<div class="card animate-on-scroll">
|
||||||
<table>
|
<div class="table-wrap">
|
||||||
<thead>
|
<table>
|
||||||
<tr>
|
<thead>
|
||||||
<th>Category</th>
|
<tr>
|
||||||
<th class="text-right">Monthly Avg</th>
|
<th>Category</th>
|
||||||
<th class="text-right">Projected Annual</th>
|
<th class="text-right">Monthly Avg</th>
|
||||||
</tr>
|
<th class="text-right">Projected Annual</th>
|
||||||
</thead>
|
<th style="width:200px;">Pace</th>
|
||||||
<tbody>
|
</tr>
|
||||||
{{range $cat, $avg := $d.MonthlyAvg}}
|
</thead>
|
||||||
<tr>
|
<tbody>
|
||||||
<td>{{$cat}}</td>
|
{{range $cat, $avg := $d.MonthlyAvg}}
|
||||||
<td class="cents">€{{printf "%.2f" $avg}}</td>
|
<td style="font-weight:500;">{{$cat}}</td>
|
||||||
<td class="cents">€{{printf "%.2f" (mul $avg 12)}}</td>
|
<td class="cents negative">€{{printf "%.2f" $avg}}</td>
|
||||||
</tr>
|
<td class="cents negative">€{{printf "%.2f" (mul $avg 12)}}</td>
|
||||||
{{end}}
|
<td>
|
||||||
</tbody>
|
<div style="background:var(--bg3); border-radius:6px; height:6px; overflow:hidden;">
|
||||||
</table>
|
<div style="height:100%; border-radius:6px; background:var(--accent);
|
||||||
|
width:{{round (mul (div (round (mul $avg 100)) (div $d.AnnualTotal 12)) 100)}}%;
|
||||||
|
max-width:100%;"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
const isDark = () => document.documentElement.getAttribute('data-theme') === 'dark';
|
||||||
|
const gridC = () => isDark() ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.06)';
|
||||||
|
const textC = () => isDark() ? '#5c6585' : '#8a92b0';
|
||||||
|
|
||||||
|
const catColors = [
|
||||||
|
'#6979f8','#f87171','#fbbf24','#34d399','#a78bfa',
|
||||||
|
'#f472b6','#38bdf8','#fb923c','#4ade80','#e879f9',
|
||||||
|
'#94a3b8','#22d3ee','#f97316','#a3e635',
|
||||||
|
];
|
||||||
|
|
||||||
new Chart(document.getElementById('projChart'), {
|
new Chart(document.getElementById('projChart'), {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: {
|
data: {
|
||||||
labels: [{{range $cat, $_ := $d.MonthlyAvg}}"{{$cat}}",{{end}}],
|
labels: [{{range $cat, $_ := $d.MonthlyAvg}}"{{$cat}}",{{end}}],
|
||||||
datasets: [{
|
datasets: [{
|
||||||
label: 'Monthly Avg (€)',
|
label: 'Monthly Avg (€)',
|
||||||
data: [{{range $cat, $avg := $d.MonthlyAvg}}{{$avg}},{{end}}],
|
data: [{{range $_, $avg := $d.MonthlyAvg}}{{$avg}},{{end}}],
|
||||||
backgroundColor: ['#FF6384','#36A2EB','#FFCE56','#4BC0C0','#9966FF','#FF9F40','#C9CBCF','#00E676','#651FFF','#FF6F00','#E91E63','#607D8B','#3F51B5','#9E9E9E'],
|
backgroundColor: catColors.map(c => c + '99'),
|
||||||
borderColor: ['#FF6384','#36A2EB','#FFCE56','#4BC0C0','#9966FF','#FF9F40','#C9CBCF','#00E676','#651FFF','#FF6F00','#E91E63','#607D8B','#3F51B5','#9E9E9E'],
|
borderColor: catColors,
|
||||||
borderWidth: 1,
|
borderWidth: 1.5,
|
||||||
borderRadius: 4,
|
borderRadius: 6,
|
||||||
borderSkipped: false,
|
borderSkipped: false,
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
animation: { duration: 1200, easing: 'easeOutQuart' },
|
animation: { duration: 900, easing: 'easeOutQuart' },
|
||||||
plugins: { legend: { display: false } },
|
plugins: { legend: { display: false } },
|
||||||
scales: {
|
scales: {
|
||||||
y: { beginAtZero: true, grid: { color: 'rgba(0,0,0,0.05)' }, ticks: { callback: v => '€' + v } },
|
y: { beginAtZero: true, grid: { color: gridC() }, ticks: { color: textC(), callback: v => '€' + v } },
|
||||||
x: { grid: { display: false } }
|
x: { grid: { display: false }, ticks: { color: textC() } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,43 +4,54 @@
|
|||||||
|
|
||||||
<div class="card animate-on-scroll">
|
<div class="card animate-on-scroll">
|
||||||
<h2>12-Month Spend by Category</h2>
|
<h2>12-Month Spend by Category</h2>
|
||||||
<canvas id="reportChart" height="300"></canvas>
|
<div style="padding-top:8px;">
|
||||||
|
<canvas id="reportChart" height="280"></canvas>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card table-wrap animate-on-scroll" style="overflow-x:auto;">
|
<div class="card animate-on-scroll" style="overflow-x:auto;">
|
||||||
<table>
|
<h2 style="margin-bottom:14px;">Breakdown by Month</h2>
|
||||||
<thead>
|
<div class="table-wrap">
|
||||||
<tr>
|
<table>
|
||||||
<th>Month</th>
|
<thead>
|
||||||
{{range $cat, $_ := $d.CategoryNames}}
|
<tr>
|
||||||
<th class="text-right" style="white-space:nowrap;">
|
<th>Month</th>
|
||||||
|
{{range $cat, $_ := $d.CategoryNames}}
|
||||||
{{$color := index $d.CategoryColors $cat}}
|
{{$color := index $d.CategoryColors $cat}}
|
||||||
{{if $color}}<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:{{$color}};margin-right:4px;vertical-align:middle;"></span>{{end}}{{$cat}}
|
<th class="text-right" style="white-space:nowrap;">
|
||||||
</th>
|
{{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}}
|
||||||
{{end}}
|
</th>
|
||||||
<th class="text-right">Total</th>
|
{{end}}
|
||||||
</tr>
|
<th class="text-right">Total</th>
|
||||||
</thead>
|
</tr>
|
||||||
<tbody>
|
</thead>
|
||||||
{{range $row := $d.MonthlyData}}
|
<tbody>
|
||||||
<tr>
|
{{range $row := $d.MonthlyData}}
|
||||||
<td><strong>{{$row.Month}}</strong></td>
|
|
||||||
{{range $cat, $_ := $d.CategoryNames}}
|
|
||||||
<td class="cents">
|
|
||||||
{{$v := index $row.Totals $cat}}
|
|
||||||
{{if $v}}<span class="{{if lt $v 0}}negative{{else}}positive{{end}}">€{{cents $v}}</span>{{else}}<span class="text-muted">—</span>{{end}}
|
|
||||||
</td>
|
|
||||||
{{end}}
|
|
||||||
{{$total := sub 0 0}}
|
{{$total := sub 0 0}}
|
||||||
{{range $_, $v := $row.Totals}}{{$total = add $total $v}}{{end}}
|
{{range $_, $v := $row.Totals}}{{$total = add $total $v}}{{end}}
|
||||||
<td class="cents"><strong class="{{if lt $total 0}}negative{{else}}positive{{end}}">€{{cents $total}}</strong></td>
|
<tr>
|
||||||
</tr>
|
<td style="font-weight:600; white-space:nowrap;">{{$row.Month}}</td>
|
||||||
{{end}}
|
{{range $cat, $_ := $d.CategoryNames}}
|
||||||
</tbody>
|
{{$v := index $row.Totals $cat}}
|
||||||
</table>
|
<td class="cents">
|
||||||
|
{{if $v}}<span class="{{if lt $v 0}}negative{{else}}positive{{end}}">€{{cents $v}}</span>{{else}}<span class="text-muted">—</span>{{end}}
|
||||||
|
</td>
|
||||||
|
{{end}}
|
||||||
|
<td class="cents" style="font-weight:600;">
|
||||||
|
<span class="{{if lt $total 0}}negative{{else}}positive{{end}}">€{{cents $total}}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
const isDark = () => document.documentElement.getAttribute('data-theme') === 'dark';
|
||||||
|
const gridC = () => isDark() ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.06)';
|
||||||
|
const textC = () => isDark() ? '#5c6585' : '#8a92b0';
|
||||||
|
|
||||||
new Chart(document.getElementById('reportChart'), {
|
new Chart(document.getElementById('reportChart'), {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: {
|
data: {
|
||||||
@ -51,24 +62,24 @@ new Chart(document.getElementById('reportChart'), {
|
|||||||
{
|
{
|
||||||
label: '{{$cat}}',
|
label: '{{$cat}}',
|
||||||
data: [{{range $d.MonthlyData}}{{$v := index .Totals $cat}}{{if $v}}{{abs $v | div 100}}{{else}}0{{end}},{{end}}],
|
data: [{{range $d.MonthlyData}}{{$v := index .Totals $cat}}{{if $v}}{{abs $v | div 100}}{{else}}0{{end}},{{end}}],
|
||||||
backgroundColor: '{{index $colors $cat}}',
|
backgroundColor: '{{index $colors $cat}}cc',
|
||||||
borderColor: '{{index $colors $cat}}',
|
borderColor: '{{index $colors $cat}}',
|
||||||
borderWidth: 0,
|
borderWidth: 0,
|
||||||
borderRadius: 2,
|
borderRadius: 3,
|
||||||
},
|
},
|
||||||
{{end}}
|
{{end}}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
animation: { duration: 1000, easing: 'easeOutQuart' },
|
animation: { duration: 900, easing: 'easeOutQuart' },
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: { position: 'bottom', labels: { boxWidth: 12, padding: 16 } },
|
legend: { position: 'bottom', labels: { boxWidth: 11, padding: 14, color: textC(), usePointStyle: true, pointStyle: 'circle' } },
|
||||||
tooltip: { mode: 'index', intersect: false }
|
tooltip: { mode: 'index', intersect: false }
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
x: { stacked: true, grid: { display: false } },
|
x: { stacked: true, grid: { display: false }, ticks: { color: textC() } },
|
||||||
y: { stacked: true, beginAtZero: true, grid: { color: 'rgba(0,0,0,0.05)' }, ticks: { callback: v => '€' + v } }
|
y: { stacked: true, beginAtZero: true, grid: { color: gridC() }, ticks: { color: textC(), callback: v => '€' + v } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,89 +1,116 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
{{$d := .}}
|
{{$d := .}}
|
||||||
<h1 style="margin-bottom: 24px;">Sharing</h1>
|
<h1 style="margin-bottom:24px;">Sharing</h1>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card animate-on-scroll">
|
||||||
<h2>Grant Read Access</h2>
|
<h2 style="margin-bottom:16px;">Grant Read Access</h2>
|
||||||
<form method="POST" class="flex" style="gap: 12px; align-items: end;">
|
<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;">
|
<div class="form-group" style="margin-bottom:0; flex:1; min-width:220px; position:relative;">
|
||||||
<label>User Email</label>
|
<label>User Email</label>
|
||||||
<input type="text" name="viewer_id" id="viewerSearch" placeholder="Search by email..." required>
|
<input type="text" name="viewer_id" id="viewerSearch"
|
||||||
<div id="searchResults" style="display:none;"></div>
|
placeholder="Search by email…" autocomplete="off" required>
|
||||||
|
<div id="searchResults"></div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary">Grant Access</button>
|
<button type="submit" class="btn btn-primary">Grant Access</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="grid-2">
|
||||||
<h2>People with Access to My Finances</h2>
|
<div class="card animate-on-scroll">
|
||||||
<div class="table-wrap">
|
<h2 style="margin-bottom:14px;">People with access to my finances</h2>
|
||||||
<table>
|
<div class="table-wrap">
|
||||||
<thead>
|
<table>
|
||||||
<tr><th>User</th><th>Granted</th><th></th></tr>
|
<thead>
|
||||||
</thead>
|
<tr><th>User</th><th>Since</th><th></th></tr>
|
||||||
<tbody>
|
</thead>
|
||||||
{{range $d.Grants}}
|
<tbody>
|
||||||
<tr>
|
{{range $d.Grants}}
|
||||||
<td>{{.ViewerID}}</td>
|
<tr>
|
||||||
<td class="text-muted">{{.CreatedAt.Format "02 Jan 2006"}}</td>
|
<td style="font-size:13px;">{{.ViewerID}}</td>
|
||||||
<td>
|
<td class="text-muted">{{.CreatedAt.Format "02 Jan 2006"}}</td>
|
||||||
<button class="btn btn-danger btn-sm" onclick="revoke('{{.ViewerID}}')">Revoke</button>
|
<td class="text-right">
|
||||||
</td>
|
<button class="btn btn-danger btn-sm" onclick="revoke('{{.ViewerID}}')">Revoke</button>
|
||||||
</tr>
|
</td>
|
||||||
{{else}}
|
</tr>
|
||||||
<tr><td colspan="3" class="text-center text-muted">No access grants yet.</td></tr>
|
{{else}}
|
||||||
{{end}}
|
<tr><td colspan="3" class="text-center text-muted" style="padding:28px;">No access grants yet.</td></tr>
|
||||||
</tbody>
|
{{end}}
|
||||||
</table>
|
</tbody>
|
||||||
</div>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="card">
|
|
||||||
<h2>Access Granted to Me</h2>
|
<div class="card animate-on-scroll">
|
||||||
<div class="table-wrap">
|
<h2 style="margin-bottom:14px;">Access granted to me</h2>
|
||||||
<table>
|
<div class="table-wrap">
|
||||||
<thead>
|
<table>
|
||||||
<tr><th>Owner</th><th>Granted</th></tr>
|
<thead>
|
||||||
</thead>
|
<tr><th>Owner</th><th>Since</th></tr>
|
||||||
<tbody>
|
</thead>
|
||||||
{{range $d.Granted}}
|
<tbody>
|
||||||
<tr>
|
{{range $d.Granted}}
|
||||||
<td>{{.OwnerID}}</td>
|
<tr>
|
||||||
<td class="text-muted">{{.CreatedAt.Format "02 Jan 2006"}}</td>
|
<td style="font-size:13px;">{{.OwnerID}}</td>
|
||||||
</tr>
|
<td class="text-muted">{{.CreatedAt.Format "02 Jan 2006"}}</td>
|
||||||
{{else}}
|
</tr>
|
||||||
<tr><td colspan="2" class="text-center text-muted">No one has granted you access yet.</td></tr>
|
{{else}}
|
||||||
{{end}}
|
<tr><td colspan="2" class="text-center text-muted" style="padding:28px;">No one has shared with you yet.</td></tr>
|
||||||
</tbody>
|
{{end}}
|
||||||
</table>
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#searchResults {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%; left: 0; right: 0;
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--border2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 50;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#searchResults div {
|
||||||
|
padding: 9px 12px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
#searchResults div:last-child { border-bottom: none; }
|
||||||
|
#searchResults div:hover { background: var(--bg3); }
|
||||||
|
</style>
|
||||||
<script>
|
<script>
|
||||||
const searchInput = document.getElementById('viewerSearch');
|
|
||||||
const resultsDiv = document.getElementById('searchResults');
|
|
||||||
let searchTimer;
|
let searchTimer;
|
||||||
|
const input = document.getElementById('viewerSearch');
|
||||||
|
const results = document.getElementById('searchResults');
|
||||||
|
|
||||||
searchInput.addEventListener('input', function() {
|
input.addEventListener('input', () => {
|
||||||
clearTimeout(searchTimer);
|
clearTimeout(searchTimer);
|
||||||
const q = this.value.trim();
|
const q = input.value.trim();
|
||||||
if (q.length < 2) { resultsDiv.style.display = 'none'; return; }
|
if (q.length < 2) { results.style.display = 'none'; return; }
|
||||||
searchTimer = setTimeout(() => {
|
searchTimer = setTimeout(() => {
|
||||||
fetch('/api/users/search?q=' + encodeURIComponent(q))
|
fetch('/api/users/search?q=' + encodeURIComponent(q))
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(users => {
|
.then(users => {
|
||||||
if (!users.length) { resultsDiv.style.display = 'none'; return; }
|
if (!users || !users.length) { results.style.display = 'none'; return; }
|
||||||
resultsDiv.innerHTML = users.map(u =>
|
results.innerHTML = users.map(u =>
|
||||||
`<div onclick="selectUser('${u.id}','${u.email}')" style="padding:6px 12px;cursor:pointer;border-bottom:1px solid #eee;">${u.email}</div>`
|
`<div onclick="selectUser('${u.id}','${u.email}')">${u.email}</div>`
|
||||||
).join('');
|
).join('');
|
||||||
resultsDiv.style.display = 'block';
|
results.style.display = 'block';
|
||||||
});
|
});
|
||||||
}, 300);
|
}, 280);
|
||||||
});
|
});
|
||||||
|
|
||||||
function selectUser(id, email) {
|
function selectUser(id, email) {
|
||||||
searchInput.value = email;
|
input.value = email;
|
||||||
resultsDiv.style.display = 'none';
|
results.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
function revoke(viewerId) {
|
function revoke(viewerId) {
|
||||||
@ -91,12 +118,9 @@ function revoke(viewerId) {
|
|||||||
fetch('/sharing/' + encodeURIComponent(viewerId), {method: 'DELETE'})
|
fetch('/sharing/' + encodeURIComponent(viewerId), {method: 'DELETE'})
|
||||||
.then(() => location.reload());
|
.then(() => location.reload());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
if (!input.contains(e.target)) results.style.display = 'none';
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
<style>
|
|
||||||
#searchResults {
|
|
||||||
position: absolute; background: #fff; border: 1px solid #ddd;
|
|
||||||
border-radius: 6px; max-height: 200px; overflow-y: auto; z-index: 100;
|
|
||||||
width: 100%; box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@ -6,9 +6,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card mb-16">
|
<div class="card mb-16">
|
||||||
<form method="GET" class="flex flex-wrap" style="gap:8px; 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 style="font-size:12px; color:#888;">Category</label>
|
<label>Category</label>
|
||||||
<select name="category">
|
<select name="category">
|
||||||
<option value="">All Categories</option>
|
<option value="">All Categories</option>
|
||||||
{{range $d.Categories}}
|
{{range $d.Categories}}
|
||||||
@ -16,18 +16,18 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="margin-bottom:0; min-width:120px;">
|
<div class="form-group" style="margin-bottom:0; min-width:130px;">
|
||||||
<label style="font-size:12px; color:#888;">Period</label>
|
<label>Period</label>
|
||||||
<select name="days">
|
<select name="days">
|
||||||
<option value="">All time</option>
|
<option value="">All time</option>
|
||||||
<option value="30" {{if eq $.Days "30"}}selected{{end}}>30 days</option>
|
<option value="30" {{if eq $.Days "30"}}selected{{end}}>30 days</option>
|
||||||
<option value="90" {{if eq $.Days "90"}}selected{{end}}>90 days</option>
|
<option value="90" {{if eq $.Days "90"}}selected{{end}}>90 days</option>
|
||||||
<option value="365" {{if eq $.Days "365"}}selected{{end}}>1 year</option>
|
<option value="365" {{if eq $.Days "365"}}selected{{end}}>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 style="font-size:12px; color:#888;">Search</label>
|
<label>Search</label>
|
||||||
<input type="text" name="search" placeholder="Search description..." value="{{.Search}}">
|
<input type="text" name="search" placeholder="Description…" value="{{.Search}}">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary">Filter</button>
|
<button type="submit" class="btn btn-primary">Filter</button>
|
||||||
{{if or $.Cat $.Search $.Days}}
|
{{if or $.Cat $.Search $.Days}}
|
||||||
@ -56,26 +56,36 @@
|
|||||||
{{range $d.Txns}}
|
{{range $d.Txns}}
|
||||||
{{$color := index $d.CategoryColors .Category}}
|
{{$color := index $d.CategoryColors .Category}}
|
||||||
<tr id="row-{{.ID}}">
|
<tr id="row-{{.ID}}">
|
||||||
<td style="white-space:nowrap;">{{dateShort .Date}}</td>
|
<td style="white-space:nowrap; color:var(--text2);">{{dateShort .Date}}</td>
|
||||||
<td class="desc-cell">{{.Description}}</td>
|
<td style="max-width:260px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{{.Description}}</td>
|
||||||
<td class="text-muted" style="font-size:13px;">
|
<td style="font-size:12.5px; color:var(--text3);">
|
||||||
{{$name := index $d.AccountNames .AccountID}}
|
{{$name := index $d.AccountNames .AccountID}}
|
||||||
{{if $name}}{{$name}}{{else}}<span style="opacity:0.4;">—</span>{{end}}
|
{{if $name}}{{$name}}{{else}}<span style="opacity:.35;">—</span>{{end}}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="cat-badge" id="cat-{{.ID}}"
|
<!-- badge shown normally -->
|
||||||
style="background:{{if $color}}{{$color}}22{{else}}#e0e0e0{{end}}; color:{{if $color}}{{$color}}{{else}}#555{{end}}; border:1px solid {{if $color}}{{$color}}44{{else}}#ccc{{end}}; padding:2px 10px; border-radius:12px; font-size:12px; font-weight:500;">
|
<span id="cat-{{.ID}}" class="badge" style="
|
||||||
{{if $color}}<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:{{$color}};margin-right:5px;vertical-align:middle;"></span>{{end}}{{.Category}}
|
background:{{if $color}}{{$color}}18{{else}}var(--bg3){{end}};
|
||||||
|
color:{{if $color}}{{$color}}{{else}}var(--text2){{end}};
|
||||||
|
border:1px solid {{if $color}}{{$color}}33{{else}}var(--border2){{end}};">
|
||||||
|
{{if $color}}<span class="category-dot" style="background:{{$color}};box-shadow:0 0 4px {{$color}}88;"></span>{{end}}
|
||||||
|
{{.Category}}
|
||||||
</span>
|
</span>
|
||||||
<select class="cat-select" id="sel-{{.ID}}" style="display:none; font-size:12px; padding:2px 6px; border-radius:8px; border:1px solid #c5cae9;"
|
<!-- inline dropdown, hidden until pencil clicked -->
|
||||||
onchange="saveCategory('{{.ID}}', this.value)" onblur="cancelEdit('{{.ID}}')">
|
<select id="sel-{{.ID}}" style="display:none; font-size:12px; padding:4px 8px;
|
||||||
|
border:1px solid var(--border2); border-radius:6px; background:var(--bg2); color:var(--text);"
|
||||||
|
onchange="saveCategory('{{.ID}}', this.value)"
|
||||||
|
onblur="cancelEdit('{{.ID}}')">
|
||||||
{{range $d.Categories}}
|
{{range $d.Categories}}
|
||||||
<option value="{{.Name}}" {{if eq .Name $.Category}}selected{{end}}>{{.Name}}</option>
|
<option value="{{.Name}}">{{.Name}}</option>
|
||||||
{{end}}
|
{{end}}
|
||||||
</select>
|
</select>
|
||||||
<button class="btn btn-outline btn-sm" onclick="editCat('{{.ID}}')" id="edit-btn-{{.ID}}" title="Edit category">✎</button>
|
<button id="edit-btn-{{.ID}}" class="btn btn-outline btn-sm"
|
||||||
|
onclick="editCat('{{.ID}}')" title="Edit category"
|
||||||
|
style="margin-left:4px; padding:3px 7px;">✎</button>
|
||||||
</td>
|
</td>
|
||||||
<td class="cents {{if lt .AmountCents 0}}negative{{else}}positive{{end}}" style="font-variant-numeric:tabular-nums; white-space:nowrap;">
|
<td class="cents {{if lt .AmountCents 0}}negative{{else}}positive{{end}}"
|
||||||
|
style="font-weight:600; white-space:nowrap;">
|
||||||
{{if lt .AmountCents 0}}−{{else}}+{{end}}€{{cents (centsAbs .AmountCents)}}
|
{{if lt .AmountCents 0}}−{{else}}+{{end}}€{{cents (centsAbs .AmountCents)}}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@ -83,7 +93,13 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr><td colspan="6" class="text-center text-muted" style="padding:40px;">No transactions found. <a href="/import">Import some</a> or <button class="btn btn-outline btn-sm" onclick="openAddModal()">add manually</button>.</td></tr>
|
<tr>
|
||||||
|
<td colspan="6" class="text-center text-muted" style="padding:44px;">
|
||||||
|
No transactions found.
|
||||||
|
<a href="/import" style="color:var(--accent);">Import some</a> or
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="openAddModal()" style="margin-left:4px;">add manually</button>.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -91,9 +107,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add Transaction Modal -->
|
<!-- Add Transaction Modal -->
|
||||||
<div id="add-modal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.4); z-index:200; align-items:center; justify-content:center;">
|
<div id="add-modal" style="display:none; position:fixed; inset:0;
|
||||||
<div class="card" style="width:420px; max-width:95vw; margin:0; animation:fadeIn 0.2s ease-out;">
|
background:rgba(0,0,0,0.55); backdrop-filter:blur(4px);
|
||||||
<h2 style="font-size:18px; font-weight:700; color:#333; margin-bottom:20px;">Add Transaction</h2>
|
z-index:300; align-items:center; justify-content:center;">
|
||||||
|
<div class="card" style="width:440px; max-width:95vw; margin:0;
|
||||||
|
border:1px solid var(--border2); box-shadow:var(--shadow-lg);
|
||||||
|
animation:fadeUp 0.2s ease-out both;">
|
||||||
|
<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>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="closeAddModal()" style="padding:3px 8px;">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Date</label>
|
<label>Date</label>
|
||||||
<input type="date" id="add-date" required>
|
<input type="date" id="add-date" required>
|
||||||
@ -104,10 +128,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Amount (€)</label>
|
<label>Amount (€)</label>
|
||||||
<div class="flex" style="gap:8px;">
|
<div style="display:flex; gap:8px;">
|
||||||
<select id="add-sign" style="width:90px; padding:8px; border:1px solid #ddd; border-radius:8px;">
|
<select id="add-sign" style="width:110px; padding:9px 10px;
|
||||||
<option value="-1">Expense</option>
|
border:1px solid var(--border2); border-radius:var(--radius-sm);
|
||||||
<option value="1">Income</option>
|
background:var(--bg2); color:var(--text);">
|
||||||
|
<option value="-1">− Expense</option>
|
||||||
|
<option value="1">+ 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="0.00" step="0.01" min="0" style="flex:1;">
|
||||||
</div>
|
</div>
|
||||||
@ -118,9 +144,7 @@
|
|||||||
{{range $d.Categories}}
|
{{range $d.Categories}}
|
||||||
<option value="{{.Name}}">{{.Name}}</option>
|
<option value="{{.Name}}">{{.Name}}</option>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if not $d.Categories}}
|
{{if not $d.Categories}}<option value="Others">Others</option>{{end}}
|
||||||
<option value="Others">Others</option>
|
|
||||||
{{end}}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@ -132,20 +156,18 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex" style="gap:8px; justify-content:flex-end; margin-top:8px;">
|
|
||||||
|
<div id="add-error" class="error" style="display:none;"></div>
|
||||||
|
|
||||||
|
<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()">Cancel</button>
|
||||||
<button class="btn btn-primary" onclick="submitAdd()">Save</button>
|
<button class="btn btn-primary" onclick="submitAdd()">Save Transaction</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="add-error" class="error" style="display:none; margin-top:8px;"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
|
||||||
.desc-cell { max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
||||||
.cat-badge { cursor:default; display:inline-flex; align-items:center; }
|
|
||||||
</style>
|
|
||||||
<script>
|
<script>
|
||||||
const catColors = {{range $k,$v := $d.CategoryColors}}"{{$k}}":"{{$v}}",{{end}};
|
const catColors = { {{range $k,$v := $d.CategoryColors}}"{{$k}}":"{{$v}}",{{end}} };
|
||||||
|
|
||||||
function editCat(id) {
|
function editCat(id) {
|
||||||
document.getElementById('cat-' + id).style.display = 'none';
|
document.getElementById('cat-' + id).style.display = 'none';
|
||||||
@ -154,15 +176,13 @@ function editCat(id) {
|
|||||||
sel.style.display = 'inline-block';
|
sel.style.display = 'inline-block';
|
||||||
sel.focus();
|
sel.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelEdit(id) {
|
function cancelEdit(id) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document.getElementById('cat-' + id).style.display = 'inline-flex';
|
document.getElementById('cat-' + id).style.display = 'inline-flex';
|
||||||
document.getElementById('edit-btn-' + id).style.display = 'inline-block';
|
document.getElementById('edit-btn-' + id).style.display = 'inline-flex';
|
||||||
document.getElementById('sel-' + id).style.display = 'none';
|
document.getElementById('sel-' + id).style.display = 'none';
|
||||||
}, 150);
|
}, 160);
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveCategory(id, cat) {
|
function saveCategory(id, cat) {
|
||||||
fetch('/api/transactions/' + id, {
|
fetch('/api/transactions/' + id, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@ -170,16 +190,17 @@ function saveCategory(id, cat) {
|
|||||||
body: JSON.stringify({category: cat})
|
body: JSON.stringify({category: cat})
|
||||||
}).then(r => {
|
}).then(r => {
|
||||||
if (!r.ok) return;
|
if (!r.ok) return;
|
||||||
const badge = document.getElementById('cat-' + id);
|
const color = catColors[cat] || '';
|
||||||
const color = catColors[cat] || '';
|
const badge = document.getElementById('cat-' + id);
|
||||||
badge.style.background = color ? color + '22' : '#e0e0e0';
|
badge.style.background = color ? color + '18' : 'var(--bg3)';
|
||||||
badge.style.color = color || '#555';
|
badge.style.color = color || 'var(--text2)';
|
||||||
badge.style.border = '1px solid ' + (color ? color + '44' : '#ccc');
|
badge.style.borderColor = color ? color + '33' : 'var(--border2)';
|
||||||
badge.innerHTML = (color ? `<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:${color};margin-right:5px;vertical-align:middle;"></span>` : '') + cat;
|
badge.innerHTML = (color
|
||||||
|
? `<span class="category-dot" style="background:${color};box-shadow:0 0 4px ${color}88;"></span>`
|
||||||
|
: '') + cat;
|
||||||
cancelEdit(id);
|
cancelEdit(id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function delTxn(id) {
|
function delTxn(id) {
|
||||||
if (!confirm('Delete this transaction?')) return;
|
if (!confirm('Delete this transaction?')) return;
|
||||||
fetch('/api/transactions/' + id, {method: 'DELETE'}).then(r => {
|
fetch('/api/transactions/' + id, {method: 'DELETE'}).then(r => {
|
||||||
@ -188,47 +209,42 @@ function delTxn(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openAddModal() {
|
function openAddModal() {
|
||||||
const modal = document.getElementById('add-modal');
|
document.getElementById('add-modal').style.display = 'flex';
|
||||||
modal.style.display = 'flex';
|
document.getElementById('add-date').value = new Date().toISOString().slice(0, 10);
|
||||||
document.getElementById('add-date').value = new Date().toISOString().slice(0,10);
|
|
||||||
document.getElementById('add-desc').focus();
|
document.getElementById('add-desc').focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeAddModal() {
|
function closeAddModal() {
|
||||||
document.getElementById('add-modal').style.display = 'none';
|
document.getElementById('add-modal').style.display = 'none';
|
||||||
document.getElementById('add-error').style.display = 'none';
|
document.getElementById('add-error').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitAdd() {
|
function submitAdd() {
|
||||||
const date = document.getElementById('add-date').value;
|
const date = document.getElementById('add-date').value;
|
||||||
const desc = document.getElementById('add-desc').value.trim();
|
const desc = document.getElementById('add-desc').value.trim();
|
||||||
const sign = parseInt(document.getElementById('add-sign').value);
|
const sign = parseInt(document.getElementById('add-sign').value);
|
||||||
const amt = parseFloat(document.getElementById('add-amount').value);
|
const amt = parseFloat(document.getElementById('add-amount').value);
|
||||||
const cat = document.getElementById('add-cat').value;
|
const cat = document.getElementById('add-cat').value;
|
||||||
const account = document.getElementById('add-account').value;
|
const acct = document.getElementById('add-account').value;
|
||||||
|
const errEl = document.getElementById('add-error');
|
||||||
|
|
||||||
if (!date || !desc || isNaN(amt) || amt <= 0) {
|
if (!date || !desc || isNaN(amt) || amt <= 0) {
|
||||||
const err = document.getElementById('add-error');
|
errEl.textContent = 'Please fill in date, description, and a positive amount.';
|
||||||
err.textContent = 'Please fill in date, description, and a positive amount.';
|
errEl.style.display = 'block';
|
||||||
err.style.display = 'block';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
errEl.style.display = 'none';
|
||||||
|
|
||||||
const amountCents = Math.round(amt * 100) * sign;
|
|
||||||
fetch('/api/transactions', {
|
fetch('/api/transactions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({account_id: account, date, description: desc, amount_cents: amountCents, 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) { document.getElementById('add-error').textContent = 'Failed to save.'; document.getElementById('add-error').style.display = 'block'; return; }
|
if (!r.ok) { errEl.textContent = 'Failed to save.'; errEl.style.display = 'block'; return; }
|
||||||
closeAddModal();
|
closeAddModal();
|
||||||
location.reload();
|
location.reload();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('add-modal').addEventListener('click', function(e) {
|
document.getElementById('add-modal').addEventListener('click', e => { if (e.target === e.currentTarget) closeAddModal(); });
|
||||||
if (e.target === this) closeAddModal();
|
|
||||||
});
|
|
||||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeAddModal(); });
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeAddModal(); });
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user