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
+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