feat(finance): improve UX across dashboard, transactions, reports and categories
- Fix sortStrings no-op bug that caused balance trend chart to display in random date order - Fix reports table referencing wrong scope for row totals ($.Totals → .Totals per row) - Add income vs expense split cards on dashboard alongside net figure - Add budget progress bars on dashboard using existing category budget_cents data - Color-coded category badges throughout using each category's configured color - Replace window.prompt() category editor in transactions with inline dropdown - Replace window.prompt() budget editor in categories with inline input field - Add manual transaction entry modal (POST /api/transactions) so users don't need CSV for every entry - Show account names instead of raw MongoDB IDs in the transaction list - Add clampPct and isOver template helpers for budget bar rendering Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
13b7149614
commit
452f97e6d9
@ -9,6 +9,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -77,6 +78,22 @@ func parseTmpl(files ...string) *template.Template {
|
|||||||
"round": func(f float64) float64 {
|
"round": func(f float64) float64 {
|
||||||
return math.Round(f)
|
return math.Round(f)
|
||||||
},
|
},
|
||||||
|
"clampPct": func(spent, budget int64) int64 {
|
||||||
|
if budget <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
pct := int64(float64(spent) / float64(budget) * 100)
|
||||||
|
if pct > 100 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
if pct < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return pct
|
||||||
|
},
|
||||||
|
"isOver": func(spent, budget int64) bool {
|
||||||
|
return budget > 0 && spent > budget
|
||||||
|
},
|
||||||
"jsonVals": func(m map[string]int64) template.JS {
|
"jsonVals": func(m map[string]int64) template.JS {
|
||||||
var vals []string
|
var vals []string
|
||||||
for _, v := range m {
|
for _, v := range m {
|
||||||
@ -252,16 +269,41 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
balPoints = balPoints[len(balPoints)-90:]
|
balPoints = balPoints[len(balPoints)-90:]
|
||||||
}
|
}
|
||||||
|
|
||||||
render(w, dashboardTmpl, &DashboardData{
|
// compute income vs expense split for this month
|
||||||
UserID: a.UserID,
|
thisMonthIncome := int64(0)
|
||||||
Email: a.Email,
|
thisMonthExpense := int64(0)
|
||||||
Title: "Dashboard",
|
for _, amt := range thisMonth.ByCategory {
|
||||||
Route: "dashboard",
|
if amt > 0 {
|
||||||
IsOwner: true,
|
thisMonthIncome += amt
|
||||||
ThisMonth: thisMonth,
|
} else {
|
||||||
LastMonth: lastMonth,
|
thisMonthExpense += amt
|
||||||
RecentTxns: recent,
|
}
|
||||||
BalanceTrend: balPoints,
|
}
|
||||||
|
|
||||||
|
// budget data: map category name -> budget cents
|
||||||
|
catBudgets := make(map[string]int64)
|
||||||
|
catColors := make(map[string]string)
|
||||||
|
for _, c := range cats {
|
||||||
|
if c.BudgetCents > 0 {
|
||||||
|
catBudgets[c.Name] = c.BudgetCents
|
||||||
|
}
|
||||||
|
catColors[c.Name] = c.Color
|
||||||
|
}
|
||||||
|
|
||||||
|
render(w, dashboardTmpl, map[string]interface{}{
|
||||||
|
"UserID": a.UserID,
|
||||||
|
"Email": a.Email,
|
||||||
|
"Title": "Dashboard",
|
||||||
|
"Route": "dashboard",
|
||||||
|
"IsOwner": true,
|
||||||
|
"ThisMonth": thisMonth,
|
||||||
|
"LastMonth": lastMonth,
|
||||||
|
"RecentTxns": recent,
|
||||||
|
"BalanceTrend": balPoints,
|
||||||
|
"ThisMonthIncome": thisMonthIncome,
|
||||||
|
"ThisMonthExpense": thisMonthExpense,
|
||||||
|
"CategoryBudgets": catBudgets,
|
||||||
|
"CategoryColors": catColors,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -306,21 +348,78 @@ func (h *Handler) Transactions(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cats, _ := h.store.getCategories(ctx, a.UserID)
|
cats, _ := h.store.getCategories(ctx, a.UserID)
|
||||||
|
accounts, _ := h.store.getAccounts(ctx, a.UserID)
|
||||||
|
|
||||||
|
accountNames := make(map[string]string)
|
||||||
|
for _, acc := range accounts {
|
||||||
|
accountNames[acc.ID] = acc.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
catColors := make(map[string]string)
|
||||||
|
for _, c := range cats {
|
||||||
|
catColors[c.Name] = c.Color
|
||||||
|
}
|
||||||
|
|
||||||
render(w, txnsTmpl, map[string]interface{}{
|
render(w, txnsTmpl, map[string]interface{}{
|
||||||
"UserID": a.UserID,
|
"UserID": a.UserID,
|
||||||
"Email": a.Email,
|
"Email": a.Email,
|
||||||
"Title": "Transactions",
|
"Title": "Transactions",
|
||||||
"Route": "transactions",
|
"Route": "transactions",
|
||||||
"IsOwner": true,
|
"IsOwner": true,
|
||||||
"Txns": txns,
|
"Txns": txns,
|
||||||
"Categories": cats,
|
"Categories": cats,
|
||||||
"Cat": cat,
|
"Accounts": accounts,
|
||||||
"Search": search,
|
"AccountNames": accountNames,
|
||||||
"Days": daysStr,
|
"CategoryColors": catColors,
|
||||||
|
"Cat": cat,
|
||||||
|
"Search": search,
|
||||||
|
"Days": daysStr,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateTransaction(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
a := getAuth(r)
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
AccountID string `json:"account_id"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
AmountCents int64 `json:"amount_cents"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
date, err := time.Parse("2006-01-02", body.Date)
|
||||||
|
if err != nil {
|
||||||
|
date = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
txn := Transaction{
|
||||||
|
ID: bson.NewObjectID().Hex(),
|
||||||
|
UserID: a.UserID,
|
||||||
|
AccountID: body.AccountID,
|
||||||
|
Date: date,
|
||||||
|
Description: body.Description,
|
||||||
|
AmountCents: body.AmountCents,
|
||||||
|
Category: body.Category,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.store.createTransactions(ctx, []Transaction{txn}); err != nil {
|
||||||
|
slog.Error("create transaction", "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(txn)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) ImportPage(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ImportPage(w http.ResponseWriter, r *http.Request) {
|
||||||
a := getAuth(r)
|
a := getAuth(r)
|
||||||
accounts, _ := h.store.getAccounts(r.Context(), a.UserID)
|
accounts, _ := h.store.getAccounts(r.Context(), a.UserID)
|
||||||
@ -1085,18 +1184,11 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("POST /sharing", h.Sharing)
|
mux.HandleFunc("POST /sharing", h.Sharing)
|
||||||
mux.HandleFunc("DELETE /sharing/{viewer_id}", h.Sharing)
|
mux.HandleFunc("DELETE /sharing/{viewer_id}", h.Sharing)
|
||||||
mux.HandleFunc("GET /api/users/search", h.SearchUsers)
|
mux.HandleFunc("GET /api/users/search", h.SearchUsers)
|
||||||
|
mux.HandleFunc("POST /api/transactions", h.CreateTransaction)
|
||||||
mux.HandleFunc("PUT /api/transactions/{id}", h.UpdateTransaction)
|
mux.HandleFunc("PUT /api/transactions/{id}", h.UpdateTransaction)
|
||||||
mux.HandleFunc("DELETE /api/transactions/{id}", h.DeleteTransaction)
|
mux.HandleFunc("DELETE /api/transactions/{id}", h.DeleteTransaction)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sortStrings(s []string) {
|
func sortStrings(s []string) {
|
||||||
if len(s) == 0 {
|
sort.Strings(s)
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type stringSlice []string
|
|
||||||
|
|
||||||
func (s stringSlice) Len() int { return len(s) }
|
|
||||||
func (s stringSlice) Less(i, j int) bool { return s[i] < s[j] }
|
|
||||||
func (s stringSlice) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
|
|
||||||
|
|||||||
@ -1,44 +1,59 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
{{$d := .}}
|
{{$d := .}}
|
||||||
<h1 style="margin-bottom: 24px;">Categories</h1>
|
<h1 style="margin-bottom:24px;">Categories</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 Category</h2>
|
||||||
<div class="form-group" style="margin-bottom: 0; flex: 1;">
|
<form method="POST" class="flex flex-wrap" style="gap:12px; align-items:flex-end;">
|
||||||
|
<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: 80px;">
|
<div class="form-group" style="margin-bottom:0; width:90px;">
|
||||||
<label>Color</label>
|
<label>Color</label>
|
||||||
<input type="color" name="color" value="#7986CB">
|
<input type="color" name="color" value="#7986CB" style="padding:4px; height:38px;">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary">Add Category</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>Color</th><th>Name</th><th>Monthly Budget</th><th></th></tr>
|
<tr>
|
||||||
|
<th style="width:40px;">Color</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Monthly Budget</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range $d.Categories}}
|
{{range $d.Categories}}
|
||||||
<tr>
|
<tr id="cat-row-{{.ID}}">
|
||||||
<td><span class="category-dot" style="background: {{.Color}}"></span></td>
|
|
||||||
<td>{{.Name}}</td>
|
|
||||||
<td>
|
<td>
|
||||||
<span class="budget-display" id="budget-{{.ID}}">
|
<span style="display:inline-block; width:22px; height:22px; border-radius:50%; background:{{.Color}}; border:2px solid {{.Color}}44; vertical-align:middle;"></span>
|
||||||
€{{cents .BudgetCents}}
|
</td>
|
||||||
|
<td style="font-weight:500;">{{.Name}}</td>
|
||||||
|
<td>
|
||||||
|
<span id="budget-display-{{.ID}}" style="font-size:14px; color:#444;">
|
||||||
|
{{if gt .BudgetCents 0}}€{{cents .BudgetCents}}{{else}}<span class="text-muted">No budget set</span>{{end}}
|
||||||
</span>
|
</span>
|
||||||
<button class="btn btn-outline btn-sm" onclick="editBudget('{{.ID}}')">edit</button>
|
<span id="budget-edit-{{.ID}}" style="display:none;">
|
||||||
|
<input type="number" id="budget-input-{{.ID}}" placeholder="0.00" step="0.01" min="0"
|
||||||
|
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;">
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="saveBudget('{{.ID}}')">Save</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="cancelBudget('{{.ID}}')">Cancel</button>
|
||||||
|
</span>
|
||||||
|
<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">No categories yet.</td></tr>
|
<tr><td colspan="4" class="text-center text-muted" style="padding:32px;">No categories yet. Add one above.</td></tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -47,19 +62,41 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
function editBudget(id) {
|
function editBudget(id) {
|
||||||
const val = prompt('Monthly budget (€):');
|
document.getElementById('budget-display-' + id).style.display = 'none';
|
||||||
if (!val) return;
|
document.getElementById('budget-btn-' + id).style.display = 'none';
|
||||||
const cents = Math.round(parseFloat(val.replace(',','.')) * 100);
|
document.getElementById('budget-edit-' + id).style.display = 'inline-flex';
|
||||||
if (isNaN(cents)) return;
|
document.getElementById('budget-edit-' + id).style.gap = '6px';
|
||||||
|
document.getElementById('budget-edit-' + id).style.alignItems = 'center';
|
||||||
|
document.getElementById('budget-input-' + id).focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelBudget(id) {
|
||||||
|
document.getElementById('budget-display-' + id).style.display = 'inline';
|
||||||
|
document.getElementById('budget-btn-' + id).style.display = 'inline-block';
|
||||||
|
document.getElementById('budget-edit-' + id).style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveBudget(id) {
|
||||||
|
const val = parseFloat(document.getElementById('budget-input-' + id).value.replace(',', '.'));
|
||||||
|
if (isNaN(val) || val < 0) return;
|
||||||
|
const cents = Math.round(val * 100);
|
||||||
fetch('/categories/' + id, {
|
fetch('/categories/' + id, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({budget_cents: cents})
|
body: JSON.stringify({budget_cents: cents})
|
||||||
}).then(() => location.reload());
|
}).then(r => {
|
||||||
|
if (!r.ok) return;
|
||||||
|
const display = document.getElementById('budget-display-' + id);
|
||||||
|
display.innerHTML = cents > 0 ? '€' + (cents / 100).toFixed(2) : '<span class="text-muted">No budget set</span>';
|
||||||
|
cancelBudget(id);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function delCat(id) {
|
function delCat(id) {
|
||||||
if (!confirm('Delete this category?')) return;
|
if (!confirm('Delete this category? Existing transactions will keep their category label.')) return;
|
||||||
fetch('/categories/' + id, {method: 'DELETE'}).then(() => location.reload());
|
fetch('/categories/' + id, {method: 'DELETE'}).then(r => {
|
||||||
|
if (r.ok) document.getElementById('cat-row-' + id).remove();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@ -1,39 +1,88 @@
|
|||||||
{{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>
|
<h1 style="margin-bottom:24px;">Dashboard</h1>
|
||||||
|
|
||||||
<div class="grid">
|
<!-- Summary cards -->
|
||||||
|
<div class="grid" style="grid-template-columns:repeat(auto-fit,minmax(200px,1fr));">
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>This Month</h2>
|
<h2>This Month (Net)</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</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Last Month</h2>
|
<h2>Income</h2>
|
||||||
<div class="value {{if lt $d.LastMonth.TotalCents 0}}negative{{else}}positive{{end}} animate-counter"
|
<div class="value positive animate-counter"
|
||||||
data-target="{{$d.LastMonth.TotalCents}}" data-prefix="€">€0</div>
|
data-target="{{$d.ThisMonthIncome}}" data-prefix="€">€0</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Change</h2>
|
<h2>Expenses</h2>
|
||||||
|
<div class="value negative animate-counter"
|
||||||
|
data-target="{{$d.ThisMonthExpense}}" data-prefix="€">€0</div>
|
||||||
|
</div>
|
||||||
|
<div class="card value-card animate-on-scroll">
|
||||||
|
<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</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid">
|
<!-- Charts row -->
|
||||||
|
<div class="grid" style="grid-template-columns:1fr 1fr;">
|
||||||
<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>
|
||||||
<canvas id="thisMonthChart" height="200"></canvas>
|
{{if $d.ThisMonth.ByCategory}}
|
||||||
|
<canvas id="thisMonthChart" height="220"></canvas>
|
||||||
|
{{else}}
|
||||||
|
<div class="empty-state" style="padding:32px;">No spending data this month.</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="card animate-on-scroll">
|
<div class="card animate-on-scroll">
|
||||||
<h2>Balance Trend</h2>
|
<h2>Balance Trend (90 days)</h2>
|
||||||
<canvas id="balanceChart" height="200"></canvas>
|
{{if $d.BalanceTrend}}
|
||||||
|
<canvas id="balanceChart" height="220"></canvas>
|
||||||
|
{{else}}
|
||||||
|
<div class="empty-state" style="padding:32px;">No transactions yet. <a href="/import">Import some!</a></div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Budget progress -->
|
||||||
|
{{if $d.CategoryBudgets}}
|
||||||
<div class="card animate-on-scroll">
|
<div class="card animate-on-scroll">
|
||||||
<h2>Recent Transactions</h2>
|
<h2 style="margin-bottom:16px;">Budget vs Actual (This Month)</h2>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:14px;">
|
||||||
|
{{range $cat, $budget := $d.CategoryBudgets}}
|
||||||
|
{{$spent := index $d.ThisMonth.ByCategory $cat}}
|
||||||
|
{{$color := index $d.CategoryColors $cat}}
|
||||||
|
{{$spentAbs := centsAbs $spent}}
|
||||||
|
<div>
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:baseline; margin-bottom:5px;">
|
||||||
|
<span style="font-size:14px; font-weight:500; display:flex; align-items:center; gap:6px;">
|
||||||
|
{{if $color}}<span style="width:9px;height:9px;border-radius:50%;background:{{$color}};display:inline-block;"></span>{{end}}
|
||||||
|
{{$cat}}
|
||||||
|
</span>
|
||||||
|
<span style="font-size:13px; color:#666;">
|
||||||
|
€{{cents $spentAbs}} <span style="color:#aaa;">/ €{{cents $budget}}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style="background:#f0f0f0; border-radius:6px; height:8px; overflow:hidden;">
|
||||||
|
<div style="height:100%; border-radius:6px; width:{{clampPct $spentAbs $budget}}%; transition:width 0.8s ease;
|
||||||
|
background:{{if isOver $spentAbs $budget}}#f44336{{else if $color}}{{$color}}{{else}}#3949ab{{end}};"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<p class="text-muted" style="margin-top:12px;">Set budgets in <a href="/categories">Categories</a>.</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Recent transactions -->
|
||||||
|
<div class="card animate-on-scroll">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
|
||||||
|
<h2>Recent Transactions</h2>
|
||||||
|
<a href="/transactions" class="btn btn-outline btn-sm">View all</a>
|
||||||
|
</div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@ -46,16 +95,25 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range $d.RecentTxns}}
|
{{range $d.RecentTxns}}
|
||||||
|
{{$color := index $d.CategoryColors .Category}}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{dateShort .Date}}</td>
|
<td style="white-space:nowrap;">{{dateShort .Date}}</td>
|
||||||
<td>{{.Description}}</td>
|
<td style="max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{{.Description}}</td>
|
||||||
<td><span class="badge">{{.Category}}</span></td>
|
<td>
|
||||||
<td class="cents {{if lt .AmountCents 0}}negative{{else}}positive{{end}}">
|
<span style="display:inline-flex; align-items:center; gap:5px; padding:2px 10px; border-radius:12px; font-size:12px; font-weight:500;
|
||||||
€{{cents .AmountCents}}
|
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}};">
|
||||||
|
{{if $color}}<span style="width:7px;height:7px;border-radius:50%;background:{{$color}};"></span>{{end}}
|
||||||
|
{{.Category}}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="cents {{if lt .AmountCents 0}}negative{{else}}positive{{end}}" style="white-space:nowrap;">
|
||||||
|
{{if lt .AmountCents 0}}−{{else}}+{{end}}€{{cents (centsAbs .AmountCents)}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr><td colspan="4" class="text-center text-muted">No transactions yet. <a href="/import">Import some!</a></td></tr>
|
<tr><td colspan="4" class="text-center text-muted" style="padding:32px;">No transactions yet. <a href="/import">Import some!</a></td></tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -64,50 +122,42 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
const barColors = ['#FF6384','#36A2EB','#FFCE56','#4BC0C0','#9966FF','#FF9F40','#C9CBCF','#00E676','#651FFF','#FF6F00','#E91E63','#607D8B','#3F51B5','#9E9E9E'];
|
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}} };
|
||||||
|
|
||||||
{{if $d.ThisMonth.ByCategory}}
|
{{if $d.ThisMonth.ByCategory}}
|
||||||
const thisMonthLabels = {{jsonKeys $d.ThisMonth.ByCategory}};
|
const thisMonthLabels = {{jsonKeys $d.ThisMonth.ByCategory}};
|
||||||
const thisMonthData = {{jsonVals $d.ThisMonth.ByCategory}};
|
const thisMonthData = {{jsonVals $d.ThisMonth.ByCategory}};
|
||||||
const barGrads = thisMonthData.map((_, i) => {
|
const resolvedColors = thisMonthLabels.map((k,i) => catColors[k] || barColors[i % barColors.length]);
|
||||||
const c = document.createElement('canvas').getContext('2d');
|
|
||||||
const g = c.createLinearGradient(0, 0, 0, 200);
|
|
||||||
g.addColorStop(0, barColors[i % barColors.length]);
|
|
||||||
g.addColorStop(0.6, barColors[i % barColors.length] + 'dd');
|
|
||||||
g.addColorStop(1, barColors[i % barColors.length] + '88');
|
|
||||||
return g;
|
|
||||||
});
|
|
||||||
new Chart(document.getElementById('thisMonthChart'), {
|
new Chart(document.getElementById('thisMonthChart'), {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: {
|
data: {
|
||||||
labels: thisMonthLabels,
|
labels: thisMonthLabels,
|
||||||
datasets: [{
|
datasets: [{
|
||||||
label: 'Spending (€)',
|
label: 'Amount (€)',
|
||||||
data: thisMonthData.map(v => Math.abs(v) / 100),
|
data: thisMonthData.map(v => Math.abs(v) / 100),
|
||||||
backgroundColor: barGrads,
|
backgroundColor: resolvedColors.map(c => c + 'cc'),
|
||||||
borderColor: barColors.map(c => c + 'cc'),
|
borderColor: resolvedColors,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderRadius: 4,
|
borderRadius: 5,
|
||||||
borderSkipped: false,
|
borderSkipped: false,
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
animation: {
|
animation: { duration: 1000, easing: 'easeOutQuart' },
|
||||||
duration: 1200,
|
|
||||||
easing: 'easeOutQuart'
|
|
||||||
},
|
|
||||||
plugins: { legend: { display: false } },
|
plugins: { legend: { display: false } },
|
||||||
scales: {
|
scales: {
|
||||||
y: { beginAtZero: true, grid: { color: 'rgba(0,0,0,0.05)' } },
|
y: { beginAtZero: true, grid: { color: 'rgba(0,0,0,0.05)' }, ticks: { callback: v => '€' + v } },
|
||||||
x: { grid: { display: false } }
|
x: { grid: { display: false } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if $d.BalanceTrend}}
|
{{if $d.BalanceTrend}}
|
||||||
const grad = document.createElement('canvas').getContext('2d').createLinearGradient(0,0,0,400);
|
const grad = document.createElement('canvas').getContext('2d').createLinearGradient(0,0,0,300);
|
||||||
grad.addColorStop(0, 'rgba(57,73,171,0.35)');
|
grad.addColorStop(0, 'rgba(57,73,171,0.35)');
|
||||||
grad.addColorStop(0.5, 'rgba(57,73,171,0.12)');
|
grad.addColorStop(0.6, 'rgba(57,73,171,0.1)');
|
||||||
grad.addColorStop(1, 'rgba(57,73,171,0.01)');
|
grad.addColorStop(1, 'rgba(57,73,171,0.01)');
|
||||||
new Chart(document.getElementById('balanceChart'), {
|
new Chart(document.getElementById('balanceChart'), {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
@ -120,23 +170,20 @@ new Chart(document.getElementById('balanceChart'), {
|
|||||||
backgroundColor: grad,
|
backgroundColor: grad,
|
||||||
fill: true,
|
fill: true,
|
||||||
tension: 0.4,
|
tension: 0.4,
|
||||||
pointRadius: 4,
|
pointRadius: 0,
|
||||||
pointHoverRadius: 7,
|
pointHoverRadius: 5,
|
||||||
pointBackgroundColor: '#fff',
|
pointBackgroundColor: '#fff',
|
||||||
pointBorderColor: '#3949ab',
|
pointBorderColor: '#3949ab',
|
||||||
pointBorderWidth: 3,
|
pointBorderWidth: 2,
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
animation: {
|
animation: { duration: 1200, easing: 'easeOutQuart' },
|
||||||
duration: 1500,
|
|
||||||
easing: 'easeOutQuart'
|
|
||||||
},
|
|
||||||
plugins: { legend: { display: false } },
|
plugins: { legend: { display: false } },
|
||||||
scales: {
|
scales: {
|
||||||
y: { beginAtZero: false, grid: { color: 'rgba(0,0,0,0.05)' } },
|
y: { beginAtZero: false, grid: { color: 'rgba(0,0,0,0.05)' }, ticks: { callback: v => '€' + v } },
|
||||||
x: { grid: { display: false } }
|
x: { grid: { display: false }, ticks: { maxTicksLimit: 8 } }
|
||||||
},
|
},
|
||||||
interaction: { intersect: false, mode: 'index' }
|
interaction: { intersect: false, mode: 'index' }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,33 +1,39 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
{{$d := .}}
|
{{$d := .}}
|
||||||
<h1 style="margin-bottom: 24px;">Monthly Reports</h1>
|
<h1 style="margin-bottom:24px;">Monthly Reports</h1>
|
||||||
|
|
||||||
<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>
|
<canvas id="reportChart" height="300"></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card table-wrap">
|
<div class="card table-wrap animate-on-scroll" style="overflow-x:auto;">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Month</th>
|
<th>Month</th>
|
||||||
{{range $cat, $_ := $d.CategoryNames}}
|
{{range $cat, $_ := $d.CategoryNames}}
|
||||||
<th class="text-right">{{$cat}}</th>
|
<th class="text-right" style="white-space:nowrap;">
|
||||||
|
{{$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>
|
||||||
{{end}}
|
{{end}}
|
||||||
<th class="text-right">Total</th>
|
<th class="text-right">Total</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range $d.MonthlyData}}
|
{{range $row := $d.MonthlyData}}
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>{{.Month}}</strong></td>
|
<td><strong>{{$row.Month}}</strong></td>
|
||||||
{{range $cat, $_ := $d.CategoryNames}}
|
{{range $cat, $_ := $d.CategoryNames}}
|
||||||
<td class="cents">€{{cents (index $.Totals $cat)}}</td>
|
<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}}
|
{{end}}
|
||||||
{{$total := sub 0 0}}
|
{{$total := sub 0 0}}
|
||||||
{{range $_, $v := .Totals}}{{$total = add $total $v}}{{end}}
|
{{range $_, $v := $row.Totals}}{{$total = add $total $v}}{{end}}
|
||||||
<td class="cents"><strong>€{{cents $total}}</strong></td>
|
<td class="cents"><strong class="{{if lt $total 0}}negative{{else}}positive{{end}}">€{{cents $total}}</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -44,8 +50,10 @@ new Chart(document.getElementById('reportChart'), {
|
|||||||
{{range $cat, $_ := $d.CategoryNames}}
|
{{range $cat, $_ := $d.CategoryNames}}
|
||||||
{
|
{
|
||||||
label: '{{$cat}}',
|
label: '{{$cat}}',
|
||||||
data: [{{range $d.MonthlyData}}{{index .Totals $cat | abs | div 100}},{{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}}',
|
||||||
|
borderColor: '{{index $colors $cat}}',
|
||||||
|
borderWidth: 0,
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
},
|
},
|
||||||
{{end}}
|
{{end}}
|
||||||
@ -55,6 +63,7 @@ new Chart(document.getElementById('reportChart'), {
|
|||||||
responsive: true,
|
responsive: true,
|
||||||
animation: { duration: 1000, easing: 'easeOutQuart' },
|
animation: { duration: 1000, easing: 'easeOutQuart' },
|
||||||
plugins: {
|
plugins: {
|
||||||
|
legend: { position: 'bottom', labels: { boxWidth: 12, padding: 16 } },
|
||||||
tooltip: { mode: 'index', intersect: false }
|
tooltip: { mode: 'index', intersect: false }
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
{{$d := .}}
|
{{$d := .}}
|
||||||
<h1 style="margin-bottom: 24px;">Transactions</h1>
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:24px; flex-wrap:wrap; gap:12px;">
|
||||||
|
<h1>Transactions</h1>
|
||||||
|
<button class="btn btn-primary" onclick="openAddModal()">+ Add Transaction</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card mb-16">
|
<div class="card mb-16">
|
||||||
<form method="GET" class="flex flex-wrap">
|
<form method="GET" class="flex flex-wrap" style="gap:8px; align-items:flex-end;">
|
||||||
<div class="form-group" style="margin-bottom: 0; min-width: 150px;">
|
<div class="form-group" style="margin-bottom:0; min-width:160px;">
|
||||||
|
<label style="font-size:12px; color:#888;">Category</label>
|
||||||
<select name="category">
|
<select name="category">
|
||||||
<option value="">All Categories</option>
|
<option value="">All Categories</option>
|
||||||
{{range $d.Categories}}
|
{{range $d.Categories}}
|
||||||
@ -12,7 +16,8 @@
|
|||||||
{{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:120px;">
|
||||||
|
<label style="font-size:12px; color:#888;">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>
|
||||||
@ -20,14 +25,21 @@
|
|||||||
<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; 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>
|
||||||
<input type="text" name="search" placeholder="Search description..." value="{{.Search}}">
|
<input type="text" name="search" placeholder="Search 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}}
|
||||||
|
<a href="/transactions" class="btn btn-outline">Clear</a>
|
||||||
|
{{end}}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
|
||||||
|
<span class="text-muted">{{len $d.Txns}} transaction{{if ne (len $d.Txns) 1}}s{{end}}</span>
|
||||||
|
</div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@ -42,46 +54,181 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range $d.Txns}}
|
{{range $d.Txns}}
|
||||||
<tr>
|
{{$color := index $d.CategoryColors .Category}}
|
||||||
<td>{{dateShort .Date}}</td>
|
<tr id="row-{{.ID}}">
|
||||||
|
<td style="white-space:nowrap;">{{dateShort .Date}}</td>
|
||||||
<td class="desc-cell">{{.Description}}</td>
|
<td class="desc-cell">{{.Description}}</td>
|
||||||
<td>{{.AccountID}}</td>
|
<td class="text-muted" style="font-size:13px;">
|
||||||
<td>
|
{{$name := index $d.AccountNames .AccountID}}
|
||||||
<span class="badge">{{.Category}}</span>
|
{{if $name}}{{$name}}{{else}}<span style="opacity:0.4;">—</span>{{end}}
|
||||||
<button class="btn btn-outline btn-sm" onclick="editCat('{{.ID}}')">edit</button>
|
|
||||||
</td>
|
|
||||||
<td class="cents {{if lt .AmountCents 0}}negative{{else}}positive{{end}}">
|
|
||||||
€{{cents .AmountCents}}
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn-danger btn-sm" onclick="delTxn('{{.ID}}')">x</button>
|
<span class="cat-badge" id="cat-{{.ID}}"
|
||||||
|
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;">
|
||||||
|
{{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}}
|
||||||
|
</span>
|
||||||
|
<select class="cat-select" id="sel-{{.ID}}" style="display:none; font-size:12px; padding:2px 6px; border-radius:8px; border:1px solid #c5cae9;"
|
||||||
|
onchange="saveCategory('{{.ID}}', this.value)" onblur="cancelEdit('{{.ID}}')">
|
||||||
|
{{range $d.Categories}}
|
||||||
|
<option value="{{.Name}}" {{if eq .Name $.Category}}selected{{end}}>{{.Name}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="editCat('{{.ID}}')" id="edit-btn-{{.ID}}" title="Edit category">✎</button>
|
||||||
|
</td>
|
||||||
|
<td class="cents {{if lt .AmountCents 0}}negative{{else}}positive{{end}}" style="font-variant-numeric:tabular-nums; white-space:nowrap;">
|
||||||
|
{{if lt .AmountCents 0}}−{{else}}+{{end}}€{{cents (centsAbs .AmountCents)}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick="delTxn('{{.ID}}')" title="Delete">✕</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr><td colspan="6" class="text-center text-muted">No transactions found. <a href="/import">Import some!</a></td></tr>
|
<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>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 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 class="card" style="width:420px; max-width:95vw; margin:0; animation:fadeIn 0.2s ease-out;">
|
||||||
|
<h2 style="font-size:18px; font-weight:700; color:#333; margin-bottom:20px;">Add Transaction</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Date</label>
|
||||||
|
<input type="date" id="add-date" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Description</label>
|
||||||
|
<input type="text" id="add-desc" placeholder="e.g. Coffee at Starbucks" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Amount (€)</label>
|
||||||
|
<div class="flex" style="gap:8px;">
|
||||||
|
<select id="add-sign" style="width:90px; padding:8px; border:1px solid #ddd; border-radius:8px;">
|
||||||
|
<option value="-1">Expense</option>
|
||||||
|
<option value="1">Income</option>
|
||||||
|
</select>
|
||||||
|
<input type="number" id="add-amount" placeholder="0.00" step="0.01" min="0" style="flex:1;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Category</label>
|
||||||
|
<select id="add-cat">
|
||||||
|
{{range $d.Categories}}
|
||||||
|
<option value="{{.Name}}">{{.Name}}</option>
|
||||||
|
{{end}}
|
||||||
|
{{if not $d.Categories}}
|
||||||
|
<option value="Others">Others</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Account</label>
|
||||||
|
<select id="add-account">
|
||||||
|
<option value="">— none —</option>
|
||||||
|
{{range $d.Accounts}}
|
||||||
|
<option value="{{.ID}}">{{.Name}} ({{.Type}})</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex" style="gap:8px; justify-content:flex-end; margin-top:8px;">
|
||||||
|
<button class="btn btn-outline" onclick="closeAddModal()">Cancel</button>
|
||||||
|
<button class="btn btn-primary" onclick="submitAdd()">Save</button>
|
||||||
|
</div>
|
||||||
|
<div id="add-error" class="error" style="display:none; margin-top:8px;"></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}};
|
||||||
|
|
||||||
function editCat(id) {
|
function editCat(id) {
|
||||||
const cat = prompt('New category:');
|
document.getElementById('cat-' + id).style.display = 'none';
|
||||||
if (!cat) return;
|
document.getElementById('edit-btn-' + id).style.display = 'none';
|
||||||
|
const sel = document.getElementById('sel-' + id);
|
||||||
|
sel.style.display = 'inline-block';
|
||||||
|
sel.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit(id) {
|
||||||
|
setTimeout(() => {
|
||||||
|
document.getElementById('cat-' + id).style.display = 'inline-flex';
|
||||||
|
document.getElementById('edit-btn-' + id).style.display = 'inline-block';
|
||||||
|
document.getElementById('sel-' + id).style.display = 'none';
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCategory(id, cat) {
|
||||||
fetch('/api/transactions/' + id, {
|
fetch('/api/transactions/' + id, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({category: cat})
|
body: JSON.stringify({category: cat})
|
||||||
}).then(() => location.reload());
|
}).then(r => {
|
||||||
|
if (!r.ok) return;
|
||||||
|
const badge = document.getElementById('cat-' + id);
|
||||||
|
const color = catColors[cat] || '';
|
||||||
|
badge.style.background = color ? color + '22' : '#e0e0e0';
|
||||||
|
badge.style.color = color || '#555';
|
||||||
|
badge.style.border = '1px solid ' + (color ? color + '44' : '#ccc');
|
||||||
|
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;
|
||||||
|
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'})
|
fetch('/api/transactions/' + id, {method: 'DELETE'}).then(r => {
|
||||||
.then(() => location.reload());
|
if (r.ok) document.getElementById('row-' + id).remove();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openAddModal() {
|
||||||
|
const modal = document.getElementById('add-modal');
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
document.getElementById('add-date').value = new Date().toISOString().slice(0,10);
|
||||||
|
document.getElementById('add-desc').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAddModal() {
|
||||||
|
document.getElementById('add-modal').style.display = 'none';
|
||||||
|
document.getElementById('add-error').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitAdd() {
|
||||||
|
const date = document.getElementById('add-date').value;
|
||||||
|
const desc = document.getElementById('add-desc').value.trim();
|
||||||
|
const sign = parseInt(document.getElementById('add-sign').value);
|
||||||
|
const amt = parseFloat(document.getElementById('add-amount').value);
|
||||||
|
const cat = document.getElementById('add-cat').value;
|
||||||
|
const account = document.getElementById('add-account').value;
|
||||||
|
|
||||||
|
if (!date || !desc || isNaN(amt) || amt <= 0) {
|
||||||
|
const err = document.getElementById('add-error');
|
||||||
|
err.textContent = 'Please fill in date, description, and a positive amount.';
|
||||||
|
err.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const amountCents = Math.round(amt * 100) * sign;
|
||||||
|
fetch('/api/transactions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({account_id: account, date, description: desc, amount_cents: amountCents, category: cat})
|
||||||
|
}).then(r => {
|
||||||
|
if (!r.ok) { document.getElementById('add-error').textContent = 'Failed to save.'; document.getElementById('add-error').style.display = 'block'; return; }
|
||||||
|
closeAddModal();
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('add-modal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) closeAddModal();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeAddModal(); });
|
||||||
</script>
|
</script>
|
||||||
<style>
|
|
||||||
.desc-cell { max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
</style>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user