Add atomic bulk transaction editing with opt-in field changes
This commit is contained in:
+515
-103
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user