Add atomic bulk transaction editing with opt-in field changes

This commit is contained in:
Lars Nolden
2026-09-19 13:01:43 +02:00
parent 9cc3130b4f
commit d787150f2d
6 changed files with 966 additions and 105 deletions
+15
View File
@@ -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.
+88
View File
@@ -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"`
+239
View File
@@ -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)
}
}
})
}
+515 -103
View File
@@ -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<Transaction | null>(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<string>(),
}));
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 (
<>
<div className="section-heading">
@@ -176,7 +216,21 @@ export function Transactions({
<h2>Transactions</h2>
<p>Your bank facts stay untouched. Make the meaning your own.</p>
</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>
<Filters
data={data}
@@ -228,15 +282,91 @@ export function Transactions({
))}
</select>
<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>
</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 ? (
<>
<div className="table-scroll">
<table>
<thead>
<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>Transaction</th>
<th>Category / tags</th>
@@ -245,108 +375,128 @@ export function Transactions({
</tr>
</thead>
<tbody>
{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 (
<tr key={f.id}>
<td>
<span className="nowrap">{f.booking_date}</span>
<small>
{data.accounts.find((a) => a.id === f.account_id)
?.display_name || f.account_id}
</small>
{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 (
<tr
key={f.id}
className={
bulk && selection.ids.has(f.id)
? "transaction-selected"
: undefined
}
>
{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>
<button
className="transaction-link"
onClick={() => setEditing(tx)}
>
<span className={`transaction-icon ${e.kind}`}>
{moves ? (
<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"}`}
)}
<td>
<span className="nowrap">{f.booking_date}</span>
<small>
{data.accounts.find((a) => a.id === f.account_id)
?.display_name || f.account_id}
</small>
</td>
<td>
<button
className="transaction-link"
aria-pressed={
bulk ? selection.ids.has(f.id) : undefined
}
onClick={() =>
bulk ? toggleSelected(f.id) : setEditing(tx)
}
>
{money(f.amount, f.currency)}
</td>
</tr>
);
})}
<span className={`transaction-icon ${e.kind}`}>
{moves ? (
<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>
</table>
</div>
@@ -399,9 +549,271 @@ export function Transactions({
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({
data,
transaction,
+102
View File
@@ -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;
+7 -2
View File
@@ -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<HTMLDialogElement>(null);
const titleID = useId();
@@ -43,7 +45,7 @@ export function Modal({
className={wide ? "modal wide" : "modal"}
onCancel={(e) => {
e.preventDefault();
close();
if (dismissible) close();
}}
>
<div className="modal-header">
@@ -52,6 +54,7 @@ export function Modal({
className="icon-button"
aria-label="Close dialog"
onClick={close}
disabled={!dismissible}
>
<X size={20} />
</button>
@@ -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 (
<fieldset className="tag-picker">
<legend>Tags</legend>
<legend>{label}</legend>
{data.tags.map((tag) => (
<label className="check-chip" key={tag.id}>
<input