From d787150f2d366795b1949df2ebb5f60e78904467 Mon Sep 17 00:00:00 2001 From: Lars Nolden Date: Sat, 19 Sep 2026 13:01:43 +0200 Subject: [PATCH] Add atomic bulk transaction editing with opt-in field changes --- README.md | 15 + internal/server/server.go | 88 +++++ internal/server/server_test.go | 239 +++++++++++++ web/src/Transactions.tsx | 618 +++++++++++++++++++++++++++------ web/src/styles.css | 102 ++++++ web/src/ui.tsx | 9 +- 6 files changed, 966 insertions(+), 105 deletions(-) diff --git a/README.md b/README.md index af73f0b..ca97555 100644 --- a/README.md +++ b/README.md @@ -456,6 +456,21 @@ For private spending, leave **Include tags** empty and add `business` to **Exclu The dashboard API accepts repeated `tag_ids` and `exclude_tag_ids` query parameters, for example `?tag_ids=tag_holiday&tag_ids=tag_shared&exclude_tag_ids=tag_business`. Values are literal tag IDs, not comma-separated lists. +## Bulk edit transactions + +In **Transactions**, choose **Bulk edit**, then select individual rows, the current page, or **Select all N matching** to include every currently filtered result across pages. Selection follows you across pages; changing a filter, search, review toggle, or classification status clears it. **Clear selection** unchecks the rows; **Cancel bulk edit** leaves selection mode. + +**Edit selected** opens a change editor. Only explicitly chosen operations are applied: + +- **Category** and **Merchant** start at **Leave unchanged**. A tag-only edit preserves each selected transaction's individual category and merchant, even when they differ. +- **Add tags** adds only the chosen tags, retaining existing ones. **Remove tags** removes only the chosen tags. Adding a tag already present or removing a known tag absent from a row does not disturb its other tags. +- Category changes require only expenses or only income and a compatible leaf category. Merchant changes allow expenses and income together, with a separate **Clear merchant** choice. +- Selections containing transfers or investments can change tags, but not category or merchant. Bank facts, transaction kinds, and transfer links are never edited. + +Review the operation summary and selected count before applying. The entire batch is saved in one journal commit and marked manually classified. An invalid edit or revision conflict saves nothing and keeps the editor's choices; cancel and refresh the journal before retrying a revision conflict. Successful saves clear the selection, including when an edit makes rows disappear from the active filter. + +The bulk API is `POST /api/transactions/bulk` with `revision`, `transaction_ids`, and only the requested fields: `category_id`, `merchant_id`, `add_tag_ids`, `remove_tag_ids`. Omitted category/merchant fields preserve per-transaction values; `merchant_id: ""` explicitly clears the merchant. Tags are additive/removal operations, not a replacement list. + ## Data, backups, and recovery Back up the **entire canonical finance directory**, including registry files, journals, `config.toml` when present, and operational/recovery state, plus any separately stored environment-managed secrets. `state/openrouter.json` and `state/enablebanking.json` contain UI-managed credentials: protect backups accordingly, including the matching banking session state. Stop the service for a consistent filesystem backup. DuckDB under `cache/` can be excluded and rebuilt. diff --git a/internal/server/server.go b/internal/server/server.go index b3c32b3..454baf4 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -11,6 +11,7 @@ import ( "net" "net/http" "net/url" + "slices" "strconv" "strings" "time" @@ -47,6 +48,7 @@ func New(a *app.App, assets fs.FS, publicURL string) (http.Handler, error) { s.mux.HandleFunc("POST /api/instruments", s.instrument) s.mux.HandleFunc("POST /api/assets", s.asset) s.mux.HandleFunc("POST /api/transactions/{id}/transfer", s.transfer) + s.mux.HandleFunc("POST /api/transactions/bulk", s.bulkTransactions) s.mux.HandleFunc("POST /api/transactions/{id}", s.transaction) s.mux.HandleFunc("POST /api/manage", s.manage) s.mux.HandleFunc("POST /api/import/prepare", s.importPrepare) @@ -347,6 +349,92 @@ func (s *Server) transaction(w http.ResponseWriter, r *http.Request) { }) respond(w, v, e) } +func (s *Server) bulkTransactions(w http.ResponseWriter, r *http.Request) { + var b struct { + Revision string `json:"revision"` + TransactionIDs []string `json:"transaction_ids"` + CategoryID *string `json:"category_id"` + MerchantID *string `json:"merchant_id"` + AddTagIDs []string `json:"add_tag_ids"` + RemoveTagIDs []string `json:"remove_tag_ids"` + } + if !decode(w, r, &b) { + return + } + v, e := s.app.Mutate(r.Context(), b.Revision, func(d *domain.Dataset) error { + if len(b.TransactionIDs) == 0 { + return errors.New("select at least one transaction") + } + if b.CategoryID == nil && b.MerchantID == nil && len(b.AddTagIDs) == 0 && len(b.RemoveTagIDs) == 0 { + return errors.New("choose at least one bulk edit") + } + selected := make(map[string]bool, len(b.TransactionIDs)) + for _, id := range b.TransactionIDs { + if id == "" || selected[id] { + return errors.New("transaction IDs must be nonempty and unique") + } + selected[id] = true + } + var knownTags map[string]bool + if len(b.AddTagIDs) > 0 || len(b.RemoveTagIDs) > 0 { + knownTags = make(map[string]bool, len(d.Tags)) + for _, tag := range d.Tags { + knownTags[tag.ID] = true + } + } + addTags := make(map[string]bool, len(b.AddTagIDs)) + for _, id := range b.AddTagIDs { + if !knownTags[id] || addTags[id] { + return errors.New("added tag IDs must be known and unique") + } + addTags[id] = true + } + removeTags := make(map[string]bool, len(b.RemoveTagIDs)) + for _, id := range b.RemoveTagIDs { + if !knownTags[id] || removeTags[id] || addTags[id] { + return errors.New("removed tag IDs must be known, unique and not also added") + } + removeTags[id] = true + } + matched := 0 + provenance := domain.Provenance{Source: "manual", Timestamp: time.Now().UTC().Format(time.RFC3339)} + for i := range d.Transactions { + t := &d.Transactions[i] + if !selected[t.Facts.ID] { + continue + } + matched++ + if (b.CategoryID != nil || b.MerchantID != nil) && (t.Enrichment.Kind == "transfer" || t.Enrichment.Kind == domain.KindInvestment) { + return errors.New("category and merchant cannot be edited on transfers or investments") + } + if b.CategoryID != nil { + t.Enrichment.CategoryID = *b.CategoryID + } + if b.MerchantID != nil { + t.Enrichment.MerchantID = *b.MerchantID + if *b.MerchantID != "" { + app.LearnAlias(d, t.Facts, *b.MerchantID) + } + } + if len(removeTags) > 0 { + t.Enrichment.TagIDs = slices.DeleteFunc(t.Enrichment.TagIDs, func(id string) bool { return removeTags[id] }) + } + for _, id := range b.AddTagIDs { + if !slices.Contains(t.Enrichment.TagIDs, id) { + t.Enrichment.TagIDs = append(t.Enrichment.TagIDs, id) + } + } + t.Enrichment.Classification = provenance + } + if matched != len(selected) { + return errors.New("unknown transaction") + } + // Commit validates the complete dataset once, including category leaf/kind + // compatibility and merchant references, before writing any journal files. + return nil + }) + respond(w, v, e) +} func (s *Server) manage(w http.ResponseWriter, r *http.Request) { var b struct { Revision string `json:"revision"` diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 5c3cf8e..52f5d90 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -13,9 +13,11 @@ import ( "net/http/httptest" "net/url" "reflect" + "slices" "strings" "testing" "testing/fstest" + "time" "finance-duck/internal/analytics" "finance-duck/internal/app" @@ -515,3 +517,240 @@ func TestDashboardRepeatedTagFiltersOverHTTP(t *testing.T) { }) } } + +func TestTransactionsBulkOverHTTP(t *testing.T) { + t.Setenv("OPENROUTER_API_KEY", "") + t.Setenv("ENABLEBANKING_APP_ID", "") + t.Setenv("ENABLEBANKING_KEY_FILE", "") + t.Setenv("ENABLEBANKING_REDIRECT_URL", "") + a, err := app.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer a.Close() + state, err := a.Snapshot(context.Background()) + if err != nil { + t.Fatal(err) + } + _, err = a.Mutate(context.Background(), state.Revision, func(d *domain.Dataset) error { + d.Accounts = []domain.Account{ + {ID: "acc_current", DisplayName: "Current", Currency: "EUR", Active: true}, + {ID: "acc_savings", DisplayName: "Savings", Currency: "EUR", Active: true}, + {ID: "acc_broker", DisplayName: "Broker", Currency: "EUR", Kind: domain.AccountInvestment, Active: true}, + } + d.Categories = append(d.Categories, domain.Category{ID: "cat_food", Name: "Food", ParentID: "cat_expenses", Kind: "expense"}) + d.Tags = []domain.Tag{{ID: "tag_keep", Name: "Keep"}, {ID: "tag_remove", Name: "Remove"}, {ID: "tag_add", Name: "Add"}, {ID: "tag_absent", Name: "Absent"}} + d.Merchants = []domain.Merchant{{ID: "mer_old", Name: "Previous merchant"}, {ID: "mer_new", Name: "New merchant"}} + for _, item := range []struct { + id, account, kind, category, merchant, peer, counterparty string + amount domain.Money + tags []string + investment *domain.Investment + }{ + {"tx_a", "acc_current", "expense", domain.ExpenseFallback, "mer_old", "", "Corner Bakery", "-10.0000", []string{"tag_keep", "tag_remove"}, nil}, + {"tx_b", "acc_current", "expense", domain.ExpenseFallback, "mer_old", "", "Market Hall", "-20.0000", []string{"tag_add", "tag_keep"}, nil}, + {"tx_untouched", "acc_current", "expense", domain.ExpenseFallback, "mer_old", "", "Station Kiosk", "-3.0000", []string{"tag_remove"}, nil}, + {"tx_income", "acc_current", "income", domain.IncomeFallback, "", "", "Employer", "100.0000", []string{"tag_remove"}, nil}, + {"tx_out", "acc_current", "transfer", "", "", "tx_in", "Savings", "-25.0000", []string{"tag_keep"}, nil}, + {"tx_in", "acc_savings", "transfer", "", "", "tx_out", "Current", "25.0000", []string{}, nil}, + {"tx_investment", "acc_broker", domain.KindInvestment, "", "", "", "Deposit", "30.0000", []string{"tag_keep"}, &domain.Investment{Event: domain.EventDeposit}}, + } { + d.Transactions = append(d.Transactions, domain.Transaction{ + Facts: domain.Facts{ + ID: item.id, Source: "test", AccountID: item.account, BookingDate: "2026-02-10", ValueDate: "2026-02-11", + Amount: item.amount, Currency: "EUR", RawDescription: "Bank description " + item.id, + ExternalID: "external_" + item.id, Fingerprint: item.id, Counterparty: item.counterparty, + CounterpartyIBAN: "DE89370400440532013000", Investment: item.investment, + }, + Enrichment: domain.Enrichment{ + Kind: item.kind, CategoryID: item.category, MerchantID: item.merchant, TagIDs: item.tags, + TransferPeerID: item.peer, Classification: domain.Provenance{Source: "rules"}, + }, + }) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + h, err := New(a, fstest.MapFS{}, "") + if err != nil { + t.Fatal(err) + } + snapshot := func() app.State { + t.Helper() + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "http://localhost:8080/api/state", nil)) + if w.Code != http.StatusOK { + t.Fatalf("GET state: %d: %s", w.Code, w.Body.String()) + } + var result app.State + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatal(err) + } + return result + } + post := func(body map[string]any, status int) app.State { + t.Helper() + if _, ok := body["revision"]; !ok { + body["revision"] = snapshot().Revision + } + raw, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + r := httptest.NewRequest(http.MethodPost, "http://localhost:8080/api/transactions/bulk", strings.NewReader(string(raw))) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != status { + t.Fatalf("POST bulk: got %d, want %d: %s", w.Code, status, w.Body.String()) + } + var result app.State + if status == http.StatusOK { + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatal(err) + } + persisted := snapshot() + if result.Revision != persisted.Revision || !reflect.DeepEqual(result.Data, persisted.Data) { + t.Fatal("bulk response differs from persisted state") + } + } + return result + } + transaction := func(s app.State, id string) domain.Transaction { + t.Helper() + for _, tx := range s.Data.Transactions { + if tx.Facts.ID == id { + return tx + } + } + t.Fatalf("missing transaction %s", id) + return domain.Transaction{} + } + for _, tt := range []struct { + name string + body map[string]any + }{ + {"empty selection", map[string]any{"transaction_ids": []string{}, "add_tag_ids": []string{"tag_add"}}}, + {"empty transaction ID", map[string]any{"transaction_ids": []string{"tx_a", ""}, "add_tag_ids": []string{"tag_add"}}}, + {"duplicate transaction ID", map[string]any{"transaction_ids": []string{"tx_a", "tx_a"}, "add_tag_ids": []string{"tag_add"}}}, + {"missing transaction rolls back category merchant tags and aliases", map[string]any{"transaction_ids": []string{"tx_a", "tx_missing"}, "category_id": "cat_food", "merchant_id": "mer_new", "add_tag_ids": []string{"tag_add"}}}, + {"no operations", map[string]any{"transaction_ids": []string{"tx_a"}, "add_tag_ids": []string{}, "remove_tag_ids": []string{}}}, + {"unknown added tag", map[string]any{"transaction_ids": []string{"tx_a"}, "add_tag_ids": []string{"tag_missing"}}}, + {"unknown removed tag", map[string]any{"transaction_ids": []string{"tx_a"}, "remove_tag_ids": []string{"tag_missing"}}}, + {"duplicate added tag", map[string]any{"transaction_ids": []string{"tx_a"}, "add_tag_ids": []string{"tag_add", "tag_add"}}}, + {"duplicate removed tag", map[string]any{"transaction_ids": []string{"tx_a"}, "remove_tag_ids": []string{"tag_remove", "tag_remove"}}}, + {"overlapping tag operations", map[string]any{"transaction_ids": []string{"tx_a"}, "add_tag_ids": []string{"tag_add"}, "remove_tag_ids": []string{"tag_add"}}}, + {"nonleaf category", map[string]any{"transaction_ids": []string{"tx_a", "tx_b"}, "category_id": "cat_expenses", "add_tag_ids": []string{"tag_add"}}}, + {"unknown category", map[string]any{"transaction_ids": []string{"tx_a", "tx_b"}, "category_id": "cat_missing", "merchant_id": "mer_new"}}, + {"category cannot be cleared", map[string]any{"transaction_ids": []string{"tx_a"}, "category_id": ""}}, + {"incompatible category rolls back entire batch", map[string]any{"transaction_ids": []string{"tx_a", "tx_income"}, "category_id": "cat_food", "merchant_id": "mer_new", "add_tag_ids": []string{"tag_add"}}}, + {"unknown merchant", map[string]any{"transaction_ids": []string{"tx_a", "tx_b"}, "merchant_id": "mer_missing"}}, + {"transfer category edit", map[string]any{"transaction_ids": []string{"tx_a", "tx_out"}, "category_id": "cat_food"}}, + {"transfer merchant clear", map[string]any{"transaction_ids": []string{"tx_a", "tx_out"}, "merchant_id": ""}}, + {"investment category edit", map[string]any{"transaction_ids": []string{"tx_a", "tx_investment"}, "category_id": "cat_food"}}, + {"investment merchant clear", map[string]any{"transaction_ids": []string{"tx_a", "tx_investment"}, "merchant_id": ""}}, + {"bank facts cannot be edited", map[string]any{"transaction_ids": []string{"tx_a"}, "amount": "1.0000", "add_tag_ids": []string{"tag_add"}}}, + {"kind cannot be edited", map[string]any{"transaction_ids": []string{"tx_a"}, "kind": "income", "add_tag_ids": []string{"tag_add"}}}, + {"transfer links cannot be edited", map[string]any{"transaction_ids": []string{"tx_out"}, "transfer_peer_id": "", "add_tag_ids": []string{"tag_add"}}}, + } { + t.Run(tt.name, func(t *testing.T) { + before := snapshot() + post(tt.body, http.StatusBadRequest) + after := snapshot() + if before.Revision != after.Revision || !reflect.DeepEqual(before.Data, after.Data) { + t.Fatal("rejected batch changed persisted data or revision") + } + }) + } + t.Run("multi row edit preserves facts unrelated tags and unselected rows", func(t *testing.T) { + before := snapshot() + after := post(map[string]any{ + "transaction_ids": []string{"tx_a", "tx_b"}, "category_id": "cat_food", "merchant_id": "mer_new", + "add_tag_ids": []string{"tag_add"}, "remove_tag_ids": []string{"tag_remove", "tag_absent"}, + }, http.StatusOK) + for _, old := range before.Data.Transactions { + got := transaction(after, old.Facts.ID) + if old.Facts.ID != "tx_a" && old.Facts.ID != "tx_b" { + if !reflect.DeepEqual(old, got) { + t.Fatalf("unselected transaction changed: %s", old.Facts.ID) + } + continue + } + tags := slices.Clone(got.Enrichment.TagIDs) + slices.Sort(tags) + if !reflect.DeepEqual(tags, []string{"tag_add", "tag_keep"}) || got.Enrichment.CategoryID != "cat_food" || got.Enrichment.MerchantID != "mer_new" { + t.Fatalf("bulk changes not applied: %+v", got.Enrichment) + } + if !reflect.DeepEqual(old.Facts, got.Facts) || got.Enrichment.Kind != old.Enrichment.Kind || got.Enrichment.TransferPeerID != old.Enrichment.TransferPeerID { + t.Fatalf("immutable transaction fields changed: %s", old.Facts.ID) + } + if got.Enrichment.Classification.Source != "manual" { + t.Fatalf("missing manual provenance: %+v", got.Enrichment.Classification) + } + if _, err := time.Parse(time.RFC3339, got.Enrichment.Classification.Timestamp); err != nil { + t.Fatalf("invalid manual timestamp: %v", err) + } + } + for _, merchant := range after.Data.Merchants { + if merchant.ID == "mer_new" && (!slices.Contains(merchant.Aliases, "Corner Bakery") || !slices.Contains(merchant.Aliases, "Market Hall")) { + t.Fatalf("explicit merchant assignment did not learn aliases: %+v", merchant) + } + } + post(map[string]any{"revision": before.Revision, "transaction_ids": []string{"tx_a", "tx_b"}, "merchant_id": ""}, http.StatusConflict) + unchanged := snapshot() + if unchanged.Revision != after.Revision || !reflect.DeepEqual(unchanged.Data, after.Data) { + t.Fatal("stale batch overwrote the successful edit") + } + }) + t.Run("tag-only edits preserve individual categories merchants and transfer links", func(t *testing.T) { + before := snapshot() + after := post(map[string]any{ + "transaction_ids": []string{"tx_a", "tx_untouched", "tx_income", "tx_out", "tx_investment"}, + "add_tag_ids": []string{"tag_add"}, "remove_tag_ids": []string{"tag_remove"}, + }, http.StatusOK) + for _, id := range []string{"tx_a", "tx_untouched", "tx_income", "tx_out", "tx_investment"} { + old, got := transaction(before, id), transaction(after, id) + if !slices.Contains(got.Enrichment.TagIDs, "tag_add") || slices.Contains(got.Enrichment.TagIDs, "tag_remove") { + t.Fatalf("tags not updated on %s: %+v", id, got.Enrichment) + } + if id == "tx_out" || id == "tx_investment" { + if !slices.Contains(got.Enrichment.TagIDs, "tag_keep") { + t.Fatalf("unrelated tag removed from %s", id) + } + } + if !reflect.DeepEqual(old.Facts, got.Facts) || got.Enrichment.Kind != old.Enrichment.Kind || + got.Enrichment.CategoryID != old.Enrichment.CategoryID || got.Enrichment.MerchantID != old.Enrichment.MerchantID || + got.Enrichment.TransferPeerID != old.Enrichment.TransferPeerID || got.Enrichment.Classification.Source != "manual" { + t.Fatalf("tag edit changed other fields or omitted manual provenance on %s: %+v", id, got) + } + } + if !reflect.DeepEqual(transaction(before, "tx_in"), transaction(after, "tx_in")) { + t.Fatal("tag edit changed unselected transfer counterpart") + } + if !reflect.DeepEqual(before.Data.Merchants, after.Data.Merchants) { + t.Fatal("tag-only edits learned merchant aliases") + } + }) + t.Run("merchant clearing preserves category and tags and fallback remains selectable", func(t *testing.T) { + before := snapshot() + cleared := post(map[string]any{"transaction_ids": []string{"tx_a", "tx_b"}, "merchant_id": ""}, http.StatusOK) + for _, id := range []string{"tx_a", "tx_b"} { + old, got := transaction(before, id), transaction(cleared, id) + if got.Enrichment.MerchantID != "" || old.Enrichment.CategoryID != got.Enrichment.CategoryID || + !reflect.DeepEqual(old.Enrichment.TagIDs, got.Enrichment.TagIDs) || !reflect.DeepEqual(old.Facts, got.Facts) { + t.Fatalf("merchant clear changed unrelated fields: %+v", got) + } + } + if !reflect.DeepEqual(before.Data.Merchants, cleared.Data.Merchants) { + t.Fatal("merchant clearing changed aliases") + } + fallback := post(map[string]any{"transaction_ids": []string{"tx_a", "tx_b"}, "category_id": domain.ExpenseFallback}, http.StatusOK) + for _, id := range []string{"tx_a", "tx_b"} { + if transaction(fallback, id).Enrichment.CategoryID != domain.ExpenseFallback { + t.Fatalf("fallback category was not assigned to %s", id) + } + } + }) +} diff --git a/web/src/Transactions.tsx b/web/src/Transactions.tsx index 481560a..5d9c4e3 100644 --- a/web/src/Transactions.tsx +++ b/web/src/Transactions.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { Search, ArrowUpRight, @@ -115,6 +115,13 @@ export function Transactions({ const [status, setStatus] = useState(""); const [editing, setEditing] = useState(null); const [page, setPage] = useState(0); + const [bulk, setBulk] = useState(false); + const [bulkEditing, setBulkEditing] = useState(false); + const selectionScope = JSON.stringify([filter, query, needsReview, status]); + const [selection, setSelection] = useState(() => ({ + scope: selectionScope, + ids: new Set(), + })); const filtered = useMemo(() => { const categories = new Set(filter.category_id ? [filter.category_id] : []); let changed = true; @@ -169,6 +176,39 @@ export function Transactions({ page, Math.max(0, Math.ceil(filtered.length / 40) - 1), ); + const pageTransactions = filtered.slice( + currentPage * 40, + currentPage * 40 + 40, + ); + const selectedTransactions = useMemo( + () => filtered.filter((tx) => selection.ids.has(tx.facts.id)), + [filtered, selection.ids], + ); + // Reset before rendering children, including when shared filters change + // outside this view. A refresh may also remove rows from the matching set. + if (selection.scope !== selectionScope) { + setSelection({ scope: selectionScope, ids: new Set() }); + setBulkEditing(false); + } else if (selectedTransactions.length !== selection.ids.size) { + setSelection({ + scope: selectionScope, + ids: new Set(selectedTransactions.map((tx) => tx.facts.id)), + }); + } + const selectedPageCount = pageTransactions.reduce( + (count, tx) => count + Number(selection.ids.has(tx.facts.id)), + 0, + ); + const clearSelection = () => + setSelection({ scope: selectionScope, ids: new Set() }); + const toggleSelected = (id: string) => { + setSelection((current) => { + const ids = new Set(current.ids); + if (ids.has(id)) ids.delete(id); + else ids.add(id); + return { scope: selectionScope, ids }; + }); + }; return ( <>
@@ -176,7 +216,21 @@ export function Transactions({

Transactions

Your bank facts stay untouched. Make the meaning your own.

- {filtered.length} transactions +
+ {filtered.length} transactions + +
- Click a transaction to edit + {" "} + {bulk + ? "Click a transaction to select" + : "Click a transaction to edit"} + {bulk && ( +
+
+ + {selectedTransactions.length} selected + + + Selection follows you across pages. Changing a filter clears it. + +
+
+ + + +
+
+ )} {filtered.length ? ( <>
+ {bulk && ( + + )} @@ -245,108 +375,128 @@ export function Transactions({ - {filtered - .slice(currentPage * 40, currentPage * 40 + 40) - .map((tx) => { - const { facts: f, enrichment: e } = tx; - const investment = f.investment; - const moves = positionOnly(investment); - const security = data.instruments.find( - (i) => i.id === investment?.instrument_id, - ); - return ( - - + {bulk && ( + - - - - + - - ); - })} + + {moves ? ( + + ) : e.kind === "transfer" ? ( + + ) : f.amount.startsWith("-") ? ( + + ) : ( + + )} + + + + {data.merchants.find( + (m) => m.id === e.merchant_id, + )?.name || + f.counterparty || + (investment + ? security?.name || f.raw_description + : "Bank transaction")} + + {(!investment || + f.raw_description !== security?.name) && ( + + {f.raw_description} + + )} + {investment && ( + + {EVENTS[investment.event] || investment.event} + {investment.quantity + ? ` · ${signedQuantity(investment.quantity)} shares` + : ""} + {investment.price + ? ` @ ${investment.price} ${f.currency}` + : ""} + {moves ? " · position only, no cash" : ""} + + )} + + + + + + + + ); + })}
+ + Date / account Transaction Category / tags
- {f.booking_date} - - {data.accounts.find((a) => a.id === f.account_id) - ?.display_name || f.account_id} - + {pageTransactions.map((tx) => { + const { facts: f, enrichment: e } = tx; + const investment = f.investment; + const moves = positionOnly(investment); + const security = data.instruments.find( + (i) => i.id === investment?.instrument_id, + ); + return ( +
+ - - - - {e.kind === "transfer" - ? "Own-account transfer" - : e.kind === "investment" - ? "Investment ledger" - : categoryPath(data, e.category_id)} - -
- {e.tag_ids.map((id) => ( - - {data.tags.find((t) => t.id === id)?.name || - id} - - ))} -
-
- - {CLASSIFICATIONS[e.classification.source] ?? - e.classification.source} - - {e.classification.error && ( - - Classification error - - )} - + {f.booking_date} + + {data.accounts.find((a) => a.id === f.account_id) + ?.display_name || f.account_id} + + +
+ + {e.kind === "transfer" + ? "Own-account transfer" + : e.kind === "investment" + ? "Investment ledger" + : categoryPath(data, e.category_id)} + +
+ {e.tag_ids.map((id) => ( + + {data.tags.find((t) => t.id === id)?.name || id} + + ))} +
+
+ + {CLASSIFICATIONS[e.classification.source] ?? + e.classification.source} + + {e.classification.error && ( + + Classification error + + )} + + {money(f.amount, f.currency)} +
@@ -399,9 +549,271 @@ export function Transactions({ close={() => setEditing(null)} /> )} + {bulkEditing && selectedTransactions.length > 0 && ( + setBulkEditing(false)} + saved={() => { + setBulkEditing(false); + clearSelection(); + }} + /> + )} ); } +function BulkTransactionEditor({ + data, + transactions, + mutate, + close, + saved, +}: { + data: Dataset; + transactions: Transaction[]; + mutate: Mutate; + close: () => void; + saved: () => void; +}) { + const [categoryMode, setCategoryMode] = useState("keep"); + const [categoryId, setCategoryId] = useState(""); + const [merchantMode, setMerchantMode] = useState("keep"); + const [merchantId, setMerchantId] = useState(""); + const [addTagIds, setAddTagIds] = useState([]); + const [removeTagIds, setRemoveTagIds] = useState([]); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + const submitting = useRef(false); + const canEditMerchant = transactions.every( + (tx) => tx.enrichment.kind === "expense" || tx.enrichment.kind === "income", + ); + const categoryKind = transactions[0].enrichment.kind; + const canEditCategory = + canEditMerchant && + transactions.every((tx) => tx.enrichment.kind === categoryKind); + const operations: string[] = []; + if (categoryMode === "set" && categoryId) + operations.push(`Set category to ${categoryPath(data, categoryId)}`); + if (merchantMode === "assign" && merchantId) + operations.push( + `Set merchant to ${data.merchants.find((m) => m.id === merchantId)?.name || merchantId}`, + ); + if (merchantMode === "clear") operations.push("Clear merchant"); + if (addTagIds.length) + operations.push( + `Add tags: ${addTagIds.map((id) => data.tags.find((tag) => tag.id === id)?.name || id).join(", ")}`, + ); + if (removeTagIds.length) + operations.push( + `Remove tags: ${removeTagIds.map((id) => data.tags.find((tag) => tag.id === id)?.name || id).join(", ")}`, + ); + const valid = + operations.length > 0 && + (categoryMode === "keep" || (canEditCategory && !!categoryId)) && + (merchantMode === "keep" || + (canEditMerchant && (merchantMode === "clear" || !!merchantId))); + const closeWhenIdle = () => { + if (!submitting.current) close(); + }; + return ( + +
{ + event.preventDefault(); + if (submitting.current || !valid) return; + submitting.current = true; + setBusy(true); + setError(""); + const body: Record = { + transaction_ids: transactions.map((tx) => tx.facts.id), + }; + if (categoryMode === "set") body.category_id = categoryId; + if (merchantMode !== "keep") + body.merchant_id = merchantMode === "clear" ? "" : merchantId; + if (addTagIds.length) body.add_tag_ids = addTagIds; + if (removeTagIds.length) body.remove_tag_ids = removeTagIds; + try { + await mutate( + "/api/transactions/bulk", + body, + `${transactions.length} transactions updated`, + ); + saved(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + submitting.current = false; + setBusy(false); + } + }} + > +
+ +

+ Choose only the fields to change. Every chosen operation applies to + all {transactions.length} selected transactions, or none are saved. +

+
+
+
+ + + + {categoryMode === "set" && canEditCategory && ( + + + + )} +
+
+ + + + {merchantMode === "assign" && canEditMerchant && ( + + + + )} +
+
+
+

+ Other tags stay unchanged. Choosing a tag in one group removes + it from the other group. +

+ { + setAddTagIds(ids); + setRemoveTagIds((current) => + current.filter((id) => !ids.includes(id)), + ); + }} + /> + { + setRemoveTagIds(ids); + setAddTagIds((current) => + current.filter((id) => !ids.includes(id)), + ); + }} + /> +
+
+
+

Apply to {transactions.length} transactions

+ {operations.length ? ( +
    + {operations.map((operation, index) => ( +
  • {operation}
  • + ))} +
+ ) : ( +

No changes chosen yet.

+ )} +

+ Unselected fields stay unchanged. Saving marks each selected row + as manually classified. Bank facts, transaction kinds and transfer + links never change. +

+
+
+
+ + +
+ +
+ ); +} + function TransactionEditor({ data, transaction, diff --git a/web/src/styles.css b/web/src/styles.css index 9717d86..12600f7 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -718,6 +718,33 @@ main { padding: 17px 23px; border-bottom: 1px solid var(--line); } +.bulk-heading-actions, +.bulk-selection-actions { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 10px; +} +.bulk-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 16px; + padding: 17px 23px; + border-bottom: 1px solid var(--line); + background: #f5faf7; +} +.bulk-selection-summary { + display: flex; + flex-direction: column; + gap: 5px; +} +.bulk-selection-summary strong { + color: var(--emerald-dark); + font-size: 13px; +} .search { display: flex; align-items: center; @@ -792,6 +819,29 @@ td small { tbody tr:hover { background: #fcfefd; } +.transaction-selection { + width: 54px; + padding: 8px 10px 8px 14px; +} +.transaction-select-control { + display: flex; + align-items: center; + justify-content: center; + min-width: 30px; + min-height: 36px; + cursor: pointer; +} +.transaction-select-control input { + width: 16px; + height: 16px; + margin: 0; + accent-color: var(--emerald); + cursor: pointer; +} +.transaction-selected, +.transaction-selected:hover { + background: #eef8f3; +} .numeric { text-align: right; } @@ -967,6 +1017,38 @@ tbody tr:hover { grid-template-columns: 1fr 1fr; gap: 18px; } +.bulk-edit-fields, +.bulk-field-group { + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; +} +.bulk-edit-fields { + border: 0; + padding: 0; + margin: 0; + gap: 20px; +} +.bulk-operation-summary { + border: 1px solid var(--line); + border-radius: 6px; + padding: 16px; + background: #f5faf7; + overflow-wrap: anywhere; +} +.bulk-operation-summary h3 { + font-size: 14px; +} +.bulk-operation-summary ul { + padding-left: 20px; + margin: 12px 0; + line-height: 1.8; + font-size: 12px; +} +.bulk-operation-summary > p { + margin-top: 10px; +} .tag-picker { border: 1px solid var(--line); border-radius: 6px; @@ -1743,6 +1825,26 @@ footer span:first-child { font-size: 11px; line-height: 1.6; } + .bulk-heading-actions { + flex-shrink: 0; + flex-direction: column; + align-items: flex-end; + } + .bulk-heading-actions .button { + font-size: 11px; + white-space: nowrap; + } + .bulk-toolbar { + padding: 15px; + } + .bulk-selection-actions { + justify-content: flex-start; + width: 100%; + } + .bulk-selection-actions .button { + flex: 1 1 auto; + font-size: 11px; + } .filters { padding: 13px; gap: 11px; diff --git a/web/src/ui.tsx b/web/src/ui.tsx index 3182a6a..2ce2eac 100644 --- a/web/src/ui.tsx +++ b/web/src/ui.tsx @@ -23,11 +23,13 @@ export function Modal({ children, close, wide = false, + dismissible = true, }: { title: string; children: ReactNode; close: () => void; wide?: boolean; + dismissible?: boolean; }) { const ref = useRef(null); const titleID = useId(); @@ -43,7 +45,7 @@ export function Modal({ className={wide ? "modal wide" : "modal"} onCancel={(e) => { e.preventDefault(); - close(); + if (dismissible) close(); }} >
@@ -52,6 +54,7 @@ export function Modal({ className="icon-button" aria-label="Close dialog" onClick={close} + disabled={!dismissible} > @@ -613,11 +616,13 @@ export function TagPicker({ value, onChange, mutate, + label = "Tags", }: { data: Dataset; value: string[]; onChange: (ids: string[]) => void; mutate?: Mutate; + label?: string; }) { const [draft, setDraft] = useState(""); const [busy, setBusy] = useState(false); @@ -653,7 +658,7 @@ export function TagPicker({ }; return (
- Tags + {label} {data.tags.map((tag) => (