Add atomic bulk transaction editing with opt-in field changes
This commit is contained in:
@@ -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.
|
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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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/instruments", s.instrument)
|
||||||
s.mux.HandleFunc("POST /api/assets", s.asset)
|
s.mux.HandleFunc("POST /api/assets", s.asset)
|
||||||
s.mux.HandleFunc("POST /api/transactions/{id}/transfer", s.transfer)
|
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/transactions/{id}", s.transaction)
|
||||||
s.mux.HandleFunc("POST /api/manage", s.manage)
|
s.mux.HandleFunc("POST /api/manage", s.manage)
|
||||||
s.mux.HandleFunc("POST /api/import/prepare", s.importPrepare)
|
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)
|
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) {
|
func (s *Server) manage(w http.ResponseWriter, r *http.Request) {
|
||||||
var b struct {
|
var b struct {
|
||||||
Revision string `json:"revision"`
|
Revision string `json:"revision"`
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
"time"
|
||||||
|
|
||||||
"finance-duck/internal/analytics"
|
"finance-duck/internal/analytics"
|
||||||
"finance-duck/internal/app"
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+515
-103
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
@@ -115,6 +115,13 @@ export function Transactions({
|
|||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
const [editing, setEditing] = useState<Transaction | null>(null);
|
const [editing, setEditing] = useState<Transaction | null>(null);
|
||||||
const [page, setPage] = useState(0);
|
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<string>(),
|
||||||
|
}));
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const categories = new Set(filter.category_id ? [filter.category_id] : []);
|
const categories = new Set(filter.category_id ? [filter.category_id] : []);
|
||||||
let changed = true;
|
let changed = true;
|
||||||
@@ -169,6 +176,39 @@ export function Transactions({
|
|||||||
page,
|
page,
|
||||||
Math.max(0, Math.ceil(filtered.length / 40) - 1),
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="section-heading">
|
<div className="section-heading">
|
||||||
@@ -176,7 +216,21 @@ export function Transactions({
|
|||||||
<h2>Transactions</h2>
|
<h2>Transactions</h2>
|
||||||
<p>Your bank facts stay untouched. Make the meaning your own.</p>
|
<p>Your bank facts stay untouched. Make the meaning your own.</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="badge neutral">{filtered.length} transactions</span>
|
<div className="bulk-heading-actions">
|
||||||
|
<span className="badge neutral">{filtered.length} transactions</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button secondary"
|
||||||
|
aria-pressed={bulk}
|
||||||
|
onClick={() => {
|
||||||
|
setBulk(!bulk);
|
||||||
|
setBulkEditing(false);
|
||||||
|
clearSelection();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{bulk ? "Cancel bulk edit" : "Bulk edit"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Filters
|
<Filters
|
||||||
data={data}
|
data={data}
|
||||||
@@ -228,15 +282,91 @@ export function Transactions({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<span className="muted small">
|
<span className="muted small">
|
||||||
<SlidersHorizontal size={15} /> Click a transaction to edit
|
<SlidersHorizontal size={15} />{" "}
|
||||||
|
{bulk
|
||||||
|
? "Click a transaction to select"
|
||||||
|
: "Click a transaction to edit"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{bulk && (
|
||||||
|
<div className="bulk-toolbar">
|
||||||
|
<div className="bulk-selection-summary">
|
||||||
|
<strong role="status" aria-live="polite">
|
||||||
|
{selectedTransactions.length} selected
|
||||||
|
</strong>
|
||||||
|
<span className="muted small">
|
||||||
|
Selection follows you across pages. Changing a filter clears it.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bulk-selection-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button secondary"
|
||||||
|
disabled={
|
||||||
|
!filtered.length ||
|
||||||
|
selectedTransactions.length === filtered.length
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
setSelection({
|
||||||
|
scope: selectionScope,
|
||||||
|
ids: new Set(filtered.map((tx) => tx.facts.id)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Select all {filtered.length} matching
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button subtle"
|
||||||
|
disabled={!selectedTransactions.length}
|
||||||
|
onClick={clearSelection}
|
||||||
|
>
|
||||||
|
Clear selection
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button primary"
|
||||||
|
disabled={!selectedTransactions.length}
|
||||||
|
onClick={() => setBulkEditing(true)}
|
||||||
|
>
|
||||||
|
Edit selected ({selectedTransactions.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{filtered.length ? (
|
{filtered.length ? (
|
||||||
<>
|
<>
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
{bulk && (
|
||||||
|
<th className="transaction-selection">
|
||||||
|
<label className="transaction-select-control">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={`Select all ${pageTransactions.length} transactions on this page`}
|
||||||
|
checked={
|
||||||
|
selectedPageCount === pageTransactions.length
|
||||||
|
}
|
||||||
|
ref={(input) => {
|
||||||
|
if (input)
|
||||||
|
input.indeterminate =
|
||||||
|
selectedPageCount > 0 &&
|
||||||
|
selectedPageCount < pageTransactions.length;
|
||||||
|
}}
|
||||||
|
onChange={(event) => {
|
||||||
|
const ids = new Set(selection.ids);
|
||||||
|
for (const tx of pageTransactions) {
|
||||||
|
if (event.target.checked) ids.add(tx.facts.id);
|
||||||
|
else ids.delete(tx.facts.id);
|
||||||
|
}
|
||||||
|
setSelection({ scope: selectionScope, ids });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
<th>Date / account</th>
|
<th>Date / account</th>
|
||||||
<th>Transaction</th>
|
<th>Transaction</th>
|
||||||
<th>Category / tags</th>
|
<th>Category / tags</th>
|
||||||
@@ -245,108 +375,128 @@ export function Transactions({
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filtered
|
{pageTransactions.map((tx) => {
|
||||||
.slice(currentPage * 40, currentPage * 40 + 40)
|
const { facts: f, enrichment: e } = tx;
|
||||||
.map((tx) => {
|
const investment = f.investment;
|
||||||
const { facts: f, enrichment: e } = tx;
|
const moves = positionOnly(investment);
|
||||||
const investment = f.investment;
|
const security = data.instruments.find(
|
||||||
const moves = positionOnly(investment);
|
(i) => i.id === investment?.instrument_id,
|
||||||
const security = data.instruments.find(
|
);
|
||||||
(i) => i.id === investment?.instrument_id,
|
return (
|
||||||
);
|
<tr
|
||||||
return (
|
key={f.id}
|
||||||
<tr key={f.id}>
|
className={
|
||||||
<td>
|
bulk && selection.ids.has(f.id)
|
||||||
<span className="nowrap">{f.booking_date}</span>
|
? "transaction-selected"
|
||||||
<small>
|
: undefined
|
||||||
{data.accounts.find((a) => a.id === f.account_id)
|
}
|
||||||
?.display_name || f.account_id}
|
>
|
||||||
</small>
|
{bulk && (
|
||||||
|
<td className="transaction-selection">
|
||||||
|
<label className="transaction-select-control">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selection.ids.has(f.id)}
|
||||||
|
aria-label={`Select ${f.booking_date}, ${f.raw_description}, ${money(f.amount, f.currency)}, ${data.accounts.find((a) => a.id === f.account_id)?.display_name || f.account_id}`}
|
||||||
|
onChange={() => toggleSelected(f.id)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
)}
|
||||||
<button
|
<td>
|
||||||
className="transaction-link"
|
<span className="nowrap">{f.booking_date}</span>
|
||||||
onClick={() => setEditing(tx)}
|
<small>
|
||||||
>
|
{data.accounts.find((a) => a.id === f.account_id)
|
||||||
<span className={`transaction-icon ${e.kind}`}>
|
?.display_name || f.account_id}
|
||||||
{moves ? (
|
</small>
|
||||||
<Layers size={17} />
|
</td>
|
||||||
) : e.kind === "transfer" ? (
|
<td>
|
||||||
<ArrowLeftRight size={17} />
|
<button
|
||||||
) : f.amount.startsWith("-") ? (
|
className="transaction-link"
|
||||||
<ArrowUpRight size={17} />
|
aria-pressed={
|
||||||
) : (
|
bulk ? selection.ids.has(f.id) : undefined
|
||||||
<ArrowDownLeft size={17} />
|
}
|
||||||
)}
|
onClick={() =>
|
||||||
</span>
|
bulk ? toggleSelected(f.id) : setEditing(tx)
|
||||||
<span>
|
}
|
||||||
<strong>
|
|
||||||
{data.merchants.find(
|
|
||||||
(m) => m.id === e.merchant_id,
|
|
||||||
)?.name ||
|
|
||||||
f.counterparty ||
|
|
||||||
(investment
|
|
||||||
? security?.name || f.raw_description
|
|
||||||
: "Bank transaction")}
|
|
||||||
</strong>
|
|
||||||
{(!investment ||
|
|
||||||
f.raw_description !== security?.name) && (
|
|
||||||
<small className="description">
|
|
||||||
{f.raw_description}
|
|
||||||
</small>
|
|
||||||
)}
|
|
||||||
{investment && (
|
|
||||||
<small className="description">
|
|
||||||
{EVENTS[investment.event] ||
|
|
||||||
investment.event}
|
|
||||||
{investment.quantity
|
|
||||||
? ` · ${signedQuantity(investment.quantity)} shares`
|
|
||||||
: ""}
|
|
||||||
{investment.price
|
|
||||||
? ` @ ${investment.price} ${f.currency}`
|
|
||||||
: ""}
|
|
||||||
{moves ? " · position only, no cash" : ""}
|
|
||||||
</small>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span>
|
|
||||||
{e.kind === "transfer"
|
|
||||||
? "Own-account transfer"
|
|
||||||
: e.kind === "investment"
|
|
||||||
? "Investment ledger"
|
|
||||||
: categoryPath(data, e.category_id)}
|
|
||||||
</span>
|
|
||||||
<div className="chips">
|
|
||||||
{e.tag_ids.map((id) => (
|
|
||||||
<span className="badge" key={id}>
|
|
||||||
{data.tags.find((t) => t.id === id)?.name ||
|
|
||||||
id}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span className="badge neutral">
|
|
||||||
{CLASSIFICATIONS[e.classification.source] ??
|
|
||||||
e.classification.source}
|
|
||||||
</span>
|
|
||||||
{e.classification.error && (
|
|
||||||
<small className="text-danger">
|
|
||||||
Classification error
|
|
||||||
</small>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
className={`numeric money ${f.amount.startsWith("-") ? "" : "positive"}`}
|
|
||||||
>
|
>
|
||||||
{money(f.amount, f.currency)}
|
<span className={`transaction-icon ${e.kind}`}>
|
||||||
</td>
|
{moves ? (
|
||||||
</tr>
|
<Layers size={17} />
|
||||||
);
|
) : e.kind === "transfer" ? (
|
||||||
})}
|
<ArrowLeftRight size={17} />
|
||||||
|
) : f.amount.startsWith("-") ? (
|
||||||
|
<ArrowUpRight size={17} />
|
||||||
|
) : (
|
||||||
|
<ArrowDownLeft size={17} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong>
|
||||||
|
{data.merchants.find(
|
||||||
|
(m) => m.id === e.merchant_id,
|
||||||
|
)?.name ||
|
||||||
|
f.counterparty ||
|
||||||
|
(investment
|
||||||
|
? security?.name || f.raw_description
|
||||||
|
: "Bank transaction")}
|
||||||
|
</strong>
|
||||||
|
{(!investment ||
|
||||||
|
f.raw_description !== security?.name) && (
|
||||||
|
<small className="description">
|
||||||
|
{f.raw_description}
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
{investment && (
|
||||||
|
<small className="description">
|
||||||
|
{EVENTS[investment.event] || investment.event}
|
||||||
|
{investment.quantity
|
||||||
|
? ` · ${signedQuantity(investment.quantity)} shares`
|
||||||
|
: ""}
|
||||||
|
{investment.price
|
||||||
|
? ` @ ${investment.price} ${f.currency}`
|
||||||
|
: ""}
|
||||||
|
{moves ? " · position only, no cash" : ""}
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span>
|
||||||
|
{e.kind === "transfer"
|
||||||
|
? "Own-account transfer"
|
||||||
|
: e.kind === "investment"
|
||||||
|
? "Investment ledger"
|
||||||
|
: categoryPath(data, e.category_id)}
|
||||||
|
</span>
|
||||||
|
<div className="chips">
|
||||||
|
{e.tag_ids.map((id) => (
|
||||||
|
<span className="badge" key={id}>
|
||||||
|
{data.tags.find((t) => t.id === id)?.name || id}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="badge neutral">
|
||||||
|
{CLASSIFICATIONS[e.classification.source] ??
|
||||||
|
e.classification.source}
|
||||||
|
</span>
|
||||||
|
{e.classification.error && (
|
||||||
|
<small className="text-danger">
|
||||||
|
Classification error
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={`numeric money ${f.amount.startsWith("-") ? "" : "positive"}`}
|
||||||
|
>
|
||||||
|
{money(f.amount, f.currency)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -399,9 +549,271 @@ export function Transactions({
|
|||||||
close={() => setEditing(null)}
|
close={() => setEditing(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{bulkEditing && selectedTransactions.length > 0 && (
|
||||||
|
<BulkTransactionEditor
|
||||||
|
data={data}
|
||||||
|
transactions={selectedTransactions}
|
||||||
|
mutate={mutate}
|
||||||
|
close={() => 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<string[]>([]);
|
||||||
|
const [removeTagIds, setRemoveTagIds] = useState<string[]>([]);
|
||||||
|
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 (
|
||||||
|
<Modal
|
||||||
|
title={`Edit ${transactions.length} selected transactions`}
|
||||||
|
close={closeWhenIdle}
|
||||||
|
dismissible={!busy}
|
||||||
|
wide
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
aria-busy={busy}
|
||||||
|
onSubmit={async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (submitting.current || !valid) return;
|
||||||
|
submitting.current = true;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="form-body">
|
||||||
|
<ErrorMessage error={error} />
|
||||||
|
<p className="muted">
|
||||||
|
Choose only the fields to change. Every chosen operation applies to
|
||||||
|
all {transactions.length} selected transactions, or none are saved.
|
||||||
|
</p>
|
||||||
|
<fieldset
|
||||||
|
className="bulk-edit-fields"
|
||||||
|
disabled={busy}
|
||||||
|
aria-label="Bulk changes"
|
||||||
|
>
|
||||||
|
<div className="two-columns">
|
||||||
|
<div className="bulk-field-group">
|
||||||
|
<Field
|
||||||
|
label="Category change"
|
||||||
|
hint={
|
||||||
|
!canEditMerchant
|
||||||
|
? "Category changes are unavailable because the selection includes a transfer or investment. Tags can still be edited for every selected row."
|
||||||
|
: !canEditCategory
|
||||||
|
? "Category changes require only expenses or only income. This selection contains both; no rows will be skipped."
|
||||||
|
: "Choose a compatible leaf category, including Unclassified. Categories cannot be cleared."
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
value={categoryMode}
|
||||||
|
disabled={!canEditCategory}
|
||||||
|
onChange={(event) => setCategoryMode(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="keep">Leave category unchanged</option>
|
||||||
|
<option value="set">Set category</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
{categoryMode === "set" && canEditCategory && (
|
||||||
|
<Field label="New category">
|
||||||
|
<CategoryCombobox
|
||||||
|
data={data}
|
||||||
|
kind={categoryKind}
|
||||||
|
leavesOnly
|
||||||
|
required
|
||||||
|
disabled={busy}
|
||||||
|
placeholder="Choose a category"
|
||||||
|
value={categoryId}
|
||||||
|
onChange={setCategoryId}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="bulk-field-group">
|
||||||
|
<Field
|
||||||
|
label="Merchant change"
|
||||||
|
hint={
|
||||||
|
canEditMerchant
|
||||||
|
? "Assign a merchant or explicitly clear it for every selected transaction."
|
||||||
|
: "Merchant changes are unavailable because the selection includes a transfer or investment. Tags can still be edited for every selected row."
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
value={merchantMode}
|
||||||
|
disabled={!canEditMerchant}
|
||||||
|
onChange={(event) => setMerchantMode(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="keep">Leave merchant unchanged</option>
|
||||||
|
<option value="assign">Assign merchant</option>
|
||||||
|
<option value="clear">Clear merchant</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
{merchantMode === "assign" && canEditMerchant && (
|
||||||
|
<Field label="New merchant">
|
||||||
|
<select
|
||||||
|
value={merchantId}
|
||||||
|
required
|
||||||
|
onChange={(event) => setMerchantId(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Choose a merchant</option>
|
||||||
|
{data.merchants.map((merchant) => (
|
||||||
|
<option key={merchant.id} value={merchant.id}>
|
||||||
|
{merchant.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bulk-field-group">
|
||||||
|
<p className="muted small">
|
||||||
|
Other tags stay unchanged. Choosing a tag in one group removes
|
||||||
|
it from the other group.
|
||||||
|
</p>
|
||||||
|
<TagPicker
|
||||||
|
label="Add tags to every selected transaction"
|
||||||
|
data={data}
|
||||||
|
value={addTagIds}
|
||||||
|
onChange={(ids) => {
|
||||||
|
setAddTagIds(ids);
|
||||||
|
setRemoveTagIds((current) =>
|
||||||
|
current.filter((id) => !ids.includes(id)),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TagPicker
|
||||||
|
label="Remove tags from every selected transaction"
|
||||||
|
data={data}
|
||||||
|
value={removeTagIds}
|
||||||
|
onChange={(ids) => {
|
||||||
|
setRemoveTagIds(ids);
|
||||||
|
setAddTagIds((current) =>
|
||||||
|
current.filter((id) => !ids.includes(id)),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<section
|
||||||
|
className="bulk-operation-summary"
|
||||||
|
aria-label="Changes to apply"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<h3>Apply to {transactions.length} transactions</h3>
|
||||||
|
{operations.length ? (
|
||||||
|
<ul>
|
||||||
|
{operations.map((operation, index) => (
|
||||||
|
<li key={index}>{operation}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="muted">No changes chosen yet.</p>
|
||||||
|
)}
|
||||||
|
<p className="muted small">
|
||||||
|
Unselected fields stay unchanged. Saving marks each selected row
|
||||||
|
as manually classified. Bank facts, transaction kinds and transfer
|
||||||
|
links never change.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button secondary"
|
||||||
|
onClick={closeWhenIdle}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="button primary"
|
||||||
|
disabled={busy || !valid}
|
||||||
|
>
|
||||||
|
{busy
|
||||||
|
? "Applying…"
|
||||||
|
: `Apply to ${transactions.length} transactions`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TransactionEditor({
|
function TransactionEditor({
|
||||||
data,
|
data,
|
||||||
transaction,
|
transaction,
|
||||||
|
|||||||
@@ -718,6 +718,33 @@ main {
|
|||||||
padding: 17px 23px;
|
padding: 17px 23px;
|
||||||
border-bottom: 1px solid var(--line);
|
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 {
|
.search {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -792,6 +819,29 @@ td small {
|
|||||||
tbody tr:hover {
|
tbody tr:hover {
|
||||||
background: #fcfefd;
|
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 {
|
.numeric {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
@@ -967,6 +1017,38 @@ tbody tr:hover {
|
|||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 18px;
|
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 {
|
.tag-picker {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -1743,6 +1825,26 @@ footer span:first-child {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 1.6;
|
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 {
|
.filters {
|
||||||
padding: 13px;
|
padding: 13px;
|
||||||
gap: 11px;
|
gap: 11px;
|
||||||
|
|||||||
+7
-2
@@ -23,11 +23,13 @@ export function Modal({
|
|||||||
children,
|
children,
|
||||||
close,
|
close,
|
||||||
wide = false,
|
wide = false,
|
||||||
|
dismissible = true,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
close: () => void;
|
close: () => void;
|
||||||
wide?: boolean;
|
wide?: boolean;
|
||||||
|
dismissible?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLDialogElement>(null);
|
const ref = useRef<HTMLDialogElement>(null);
|
||||||
const titleID = useId();
|
const titleID = useId();
|
||||||
@@ -43,7 +45,7 @@ export function Modal({
|
|||||||
className={wide ? "modal wide" : "modal"}
|
className={wide ? "modal wide" : "modal"}
|
||||||
onCancel={(e) => {
|
onCancel={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
close();
|
if (dismissible) close();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
@@ -52,6 +54,7 @@ export function Modal({
|
|||||||
className="icon-button"
|
className="icon-button"
|
||||||
aria-label="Close dialog"
|
aria-label="Close dialog"
|
||||||
onClick={close}
|
onClick={close}
|
||||||
|
disabled={!dismissible}
|
||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
@@ -613,11 +616,13 @@ export function TagPicker({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
mutate,
|
mutate,
|
||||||
|
label = "Tags",
|
||||||
}: {
|
}: {
|
||||||
data: Dataset;
|
data: Dataset;
|
||||||
value: string[];
|
value: string[];
|
||||||
onChange: (ids: string[]) => void;
|
onChange: (ids: string[]) => void;
|
||||||
mutate?: Mutate;
|
mutate?: Mutate;
|
||||||
|
label?: string;
|
||||||
}) {
|
}) {
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -653,7 +658,7 @@ export function TagPicker({
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<fieldset className="tag-picker">
|
<fieldset className="tag-picker">
|
||||||
<legend>Tags</legend>
|
<legend>{label}</legend>
|
||||||
{data.tags.map((tag) => (
|
{data.tags.map((tag) => (
|
||||||
<label className="check-chip" key={tag.id}>
|
<label className="check-chip" key={tag.id}>
|
||||||
<input
|
<input
|
||||||
|
|||||||
Reference in New Issue
Block a user