feat: add linked transactions
This commit is contained in:
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## Added
|
||||
- Icons in the categories to improve viewing and usage.
|
||||
- Pre-set of categories to help users get started.
|
||||
- Linked transactions allowing to propagate changes or deletion.
|
||||
|
||||
### Docker images
|
||||
- `docker.io/rmcampos/ledger-backend:v2026.07.11.82`
|
||||
|
||||
@@ -1,6 +1,74 @@
|
||||
# Ledger — Outstanding Work
|
||||
|
||||
## Status as of end of session (2026-07-09, latest session)
|
||||
## Status as of end of session (2026-07-11, latest session)
|
||||
|
||||
**Done, not yet committed: §8 series tracking + scope-aware edit/delete**
|
||||
(requested 2026-07-11, planned via plan mode, plan saved at
|
||||
`/home/ricardo/.claude/plans/idempotent-hugging-cupcake.md`). Resolves §8's
|
||||
long-standing gap (repeat/installment rows only shared a cosmetic
|
||||
`seriesInfo` string, no real grouping key) and adds the "apply to future
|
||||
occurrences?" prompt on edit/delete the user asked for.
|
||||
|
||||
- Backend: two new nullable columns on `Transaction` — `seriesId` (Java
|
||||
`UUID.randomUUID().toString()`, `VARCHAR(36)`, `V8__transaction_series_id.sql`,
|
||||
indexed) and `seriesRepeat` (the `RepeatFrequency` the batch was created
|
||||
with, `V9__transaction_series_repeat.sql`) — both set once per batch in
|
||||
`TransactionResource#create`, identically across every generated row, both
|
||||
null for one-off transactions (no backfill for pre-existing rows — they
|
||||
keep their `seriesInfo` display string and fall back to plain single-row
|
||||
edit/delete, per §8's already-accepted tradeoff).
|
||||
- New `EditScope` enum (`THIS`/`FUTURE`). `PUT /transactions/{id}` gained an
|
||||
optional `scope` body field (default `THIS`, backward compatible);
|
||||
`DELETE /transactions/{id}` gained an optional `?scope=` query param
|
||||
(default `THIS`). `FUTURE` is only honored when the target row has a
|
||||
non-null `seriesId`.
|
||||
- Edit "this and future": `description`/`category`/`billDueDate` flat-set
|
||||
across every row from the edited occurrence onward (via new
|
||||
`seriesFrom(anchor)`, `occurredOn`-then-`id` ordered). `occurredOn` is
|
||||
**never** propagated — only the anchor row's own date changes.
|
||||
`amount` on an `INSTALLMENTS` series re-splits the entered total evenly
|
||||
across the remaining rows (reuses the existing `splitAmount` helper,
|
||||
same remainder-on-last-row rule as creation); non-INSTALLMENTS series
|
||||
flat-set every affected row to the entered amount.
|
||||
- Delete "this and future": deletes every row from the target occurrence
|
||||
onward in the same series.
|
||||
- Every delete (single or batch) calls new `renumberSeries()`, which
|
||||
relabels remaining rows' `seriesInfo` contiguously (closing gaps like
|
||||
`1/12,2/12,4/12,5/12` → `1/11,2/11,3/11...`), and clears
|
||||
`seriesId`/`seriesInfo`/`seriesRepeat` entirely if a series collapses to
|
||||
one remaining row (it silently reverts to a plain non-series
|
||||
transaction — no scope UI shown for it anymore).
|
||||
- Frontend (`Transactions.jsx` and its line-for-line duplicate block in
|
||||
`CardBills.jsx`, both updated identically): the in-row edit form shows a
|
||||
"This occurrence only" / "This and future" radio pair — and, when the
|
||||
latter is picked on an `INSTALLMENTS` row, a one-line re-split hint —
|
||||
only when `t.seriesId` is set; the delete confirm panel swaps its single
|
||||
"Delete" button for "Delete this only" / "Delete this & future" under the
|
||||
same condition. Non-series transactions render zero new UI, byte-for-byte
|
||||
unchanged from before. `TransactionsApi.remove` gained a defaulted
|
||||
`scope` param (`'THIS'`); `update`'s payload just gained one more
|
||||
optional key.
|
||||
|
||||
Verified via curl (fresh signup, INSTALLMENTS batch creation confirms
|
||||
shared `seriesId`; `PUT .../2?scope=FUTURE` on occurrence 2 of 4 re-split a
|
||||
new $90 total across occurrences 2-4 leaving occurrence 1 untouched, and
|
||||
only occurrence 2's own date changed; `DELETE .../1?scope=THIS` renumbered
|
||||
the remaining three from `2/4,3/4,4/4` to `1/3,2/3,3/3`; a further
|
||||
`DELETE ...?scope=FUTURE` collapsing a series to one row correctly nulled
|
||||
out its `seriesId`/`seriesInfo`/`seriesRepeat`; a separate uneven-total
|
||||
re-split ($100 over 3) landed `33.33/33.33/33.34`, remainder on the last
|
||||
row as designed; a plain non-series transaction created/deleted with no
|
||||
`scope` param at all, confirming backward compatibility) and via a fresh
|
||||
Playwright pass (`playwright-core` + cached Chromium installed ad hoc into
|
||||
scratchpad, no OS-level deps available so `--with-deps` wasn't used) on
|
||||
both `Transactions.jsx` and `CardBills.jsx`: scope radios and the
|
||||
INSTALLMENTS hint appear only on series rows, the re-split renders
|
||||
correctly in the row list after saving, deleting "this only" renumbers the
|
||||
badge in place, and a non-series row (`Coffee`) shows the exact pre-change
|
||||
single-button edit/delete UI with no radios. No console errors in any
|
||||
pass.
|
||||
|
||||
## Status as of end of session (2026-07-09, previous session)
|
||||
|
||||
**Product.md audit → 5 fix chunks, all done, each reviewed/committed
|
||||
individually by the user as we went.** User wrote `Product.md` (repo root,
|
||||
@@ -645,27 +713,22 @@ possibly reference the row (Budget's and Transaction's case).
|
||||
feature below — nothing outstanding right now beyond §5's demo-login bug,
|
||||
§6's backend gaps, and the new §8 item below (neither urgent).
|
||||
|
||||
## 8. Recurring-series tracking at the DB level (raised 2026-07-07, not started)
|
||||
## 8. Recurring-series tracking at the DB level — [x] done (raised 2026-07-07, resolved 2026-07-11)
|
||||
|
||||
Today a repeat/installment purchase generates N independent `Transaction`
|
||||
rows linked only by a cosmetic `seriesInfo` string (e.g. `"3/12"`) — there is
|
||||
no `series_id`/group column anywhere. Consequence, confirmed with user:
|
||||
editing or deleting one occurrence only ever touches that single row.
|
||||
Deleting one leaves a gap in the `seriesInfo` numbering (e.g.
|
||||
`1/12, 2/12, 4/12, 5/12...`) since nothing renumbers the rest, and there's
|
||||
no "edit/delete this and all future occurrences" option.
|
||||
Was: a repeat/installment purchase generated N independent `Transaction`
|
||||
rows linked only by a cosmetic `seriesInfo` string (e.g. `"3/12"`), no real
|
||||
`series_id`/group column anywhere — editing/deleting one occurrence only
|
||||
ever touched that single row, deleting one left a gap in the numbering, no
|
||||
"edit/delete this and all future occurrences" option.
|
||||
|
||||
If this is worth fixing later:
|
||||
- Add a real `series_id` (e.g. a generated UUID or a self-referential FK to
|
||||
the first row) on `Transaction`, set at generation time in
|
||||
`TransactionResource#create`, so occurrences are actually queryable as a
|
||||
group instead of only sharing a display string.
|
||||
- Decide the desired bulk behavior once tracked: renumber remaining
|
||||
`seriesInfo` labels after a delete, and/or add "delete this and all
|
||||
future" / "edit all remaining" affordances on the frontend.
|
||||
- Not urgent — no one has hit this in practice yet, purely a known gap from
|
||||
how §-recurrence was deliberately kept simple (pre-generate flat rows, no
|
||||
scheduler, no series table — see the entry above).
|
||||
Now fixed — see the 2026-07-11 session entry above for full detail:
|
||||
- [x] Real `series_id` (Java-generated UUID, `V8__transaction_series_id.sql`)
|
||||
set at generation time in `TransactionResource#create`.
|
||||
- [x] `seriesInfo` renumbers contiguously after any delete; a series
|
||||
collapsed to one row clears its series fields entirely.
|
||||
- [x] "This occurrence only" / "This and future" scope choice on both edit
|
||||
and delete, frontend + backend, including INSTALLMENTS-aware amount
|
||||
re-splitting.
|
||||
|
||||
**Done, not yet committed: Recurrence/installments** (requested 2026-07-07).
|
||||
- Backend: `Transaction` gained `seriesInfo` (nullable String, e.g. `"3/12"`,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.ledger.transaction;
|
||||
|
||||
public enum EditScope {
|
||||
THIS,
|
||||
FUTURE
|
||||
}
|
||||
@@ -41,6 +41,15 @@ public class Transaction extends PanacheEntityBase {
|
||||
@Column(name = "series_info")
|
||||
public String seriesInfo;
|
||||
|
||||
/** Opaque token shared by every row generated together in one repeat/installment batch. Not a FK, just a grouping value — see TransactionResource#create. Null for one-off transactions. */
|
||||
@Column(name = "series_id", length = 36)
|
||||
public String seriesId;
|
||||
|
||||
/** The RepeatFrequency the series was created with, set/cleared in lockstep with seriesId. Drives scope-aware edit behavior — INSTALLMENTS re-splits on "this and future" amount edits, others flat-set. */
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "series_repeat", length = 20)
|
||||
public RepeatFrequency seriesRepeat;
|
||||
|
||||
/**
|
||||
* Only meaningful for CREDIT_CARD account transactions — the bill this
|
||||
* charge was explicitly assigned to. Display/grouping tag only, same
|
||||
@@ -75,4 +84,8 @@ public class Transaction extends PanacheEntityBase {
|
||||
public static List<Transaction> findByAccount(Long accountId) {
|
||||
return list("account.id", io.quarkus.panache.common.Sort.descending("occurredOn"), accountId);
|
||||
}
|
||||
|
||||
public static List<Transaction> findBySeries(String seriesId) {
|
||||
return list("seriesId", io.quarkus.panache.common.Sort.ascending("occurredOn").and("id"), seriesId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,8 @@ public class TransactionResource {
|
||||
RepeatFrequency repeat = request.repeat != null ? request.repeat : RepeatFrequency.NONE;
|
||||
int count = repeat == RepeatFrequency.NONE ? 1 : requireOccurrences(request.occurrences);
|
||||
BigDecimal[] amounts = splitAmount(request.amount, repeat, count);
|
||||
String seriesId = count > 1 ? java.util.UUID.randomUUID().toString() : null;
|
||||
RepeatFrequency seriesRepeat = count > 1 ? repeat : null;
|
||||
|
||||
List<Transaction> created = new ArrayList<>();
|
||||
LocalDate date = request.occurredOn != null ? request.occurredOn : LocalDate.now();
|
||||
@@ -61,6 +63,8 @@ public class TransactionResource {
|
||||
txn.amount = amounts[i];
|
||||
txn.occurredOn = date;
|
||||
txn.seriesInfo = count > 1 ? (i + 1) + "/" + count : null;
|
||||
txn.seriesId = seriesId;
|
||||
txn.seriesRepeat = seriesRepeat;
|
||||
txn.billDueDate = repeat == RepeatFrequency.NONE ? request.billDueDate : null;
|
||||
txn.persist();
|
||||
created.add(txn);
|
||||
@@ -113,11 +117,12 @@ public class TransactionResource {
|
||||
throw new WebApplicationException("Cannot directly edit a credit card bill transaction", 400);
|
||||
}
|
||||
|
||||
txn.description = request.description;
|
||||
txn.amount = request.amount;
|
||||
txn.occurredOn = request.occurredOn != null ? request.occurredOn : txn.occurredOn;
|
||||
txn.category = request.categoryId != null ? Category.findById(request.categoryId) : null;
|
||||
txn.billDueDate = request.billDueDate;
|
||||
EditScope scope = request.scope != null ? request.scope : EditScope.THIS;
|
||||
if (scope == EditScope.FUTURE && txn.seriesId != null) {
|
||||
applyToSeries(txn, request);
|
||||
} else {
|
||||
applyToSingle(txn, request);
|
||||
}
|
||||
|
||||
billSync.recomputeAccountBalance(txn.account);
|
||||
if (txn.account.kind == AccountKind.CREDIT_CARD) {
|
||||
@@ -126,22 +131,107 @@ public class TransactionResource {
|
||||
return txn;
|
||||
}
|
||||
|
||||
private void applyToSingle(Transaction txn, UpdateTransactionRequest request) {
|
||||
txn.description = request.description;
|
||||
txn.amount = request.amount;
|
||||
txn.occurredOn = request.occurredOn != null ? request.occurredOn : txn.occurredOn;
|
||||
txn.category = request.categoryId != null ? Category.findById(request.categoryId) : null;
|
||||
txn.billDueDate = request.billDueDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* "This and future": description/category/billDueDate propagate to every
|
||||
* row from the anchor onward. occurredOn is never propagated — only the
|
||||
* anchor's own date changes, and seriesFrom() uses the anchor's original
|
||||
* date, so moving it can't change which rows count as "future".
|
||||
*/
|
||||
private void applyToSeries(Transaction anchor, UpdateTransactionRequest request) {
|
||||
List<Transaction> rest = seriesFrom(anchor);
|
||||
Category category = request.categoryId != null ? Category.findById(request.categoryId) : null;
|
||||
|
||||
if (anchor.seriesRepeat == RepeatFrequency.INSTALLMENTS) {
|
||||
BigDecimal[] amounts = splitAmount(request.amount, RepeatFrequency.INSTALLMENTS, rest.size());
|
||||
for (int i = 0; i < rest.size(); i++) {
|
||||
Transaction t = rest.get(i);
|
||||
t.description = request.description;
|
||||
t.category = category;
|
||||
t.billDueDate = request.billDueDate;
|
||||
t.amount = amounts[i];
|
||||
}
|
||||
} else {
|
||||
for (Transaction t : rest) {
|
||||
t.description = request.description;
|
||||
t.category = category;
|
||||
t.billDueDate = request.billDueDate;
|
||||
t.amount = request.amount;
|
||||
}
|
||||
}
|
||||
|
||||
anchor.occurredOn = request.occurredOn != null ? request.occurredOn : anchor.occurredOn;
|
||||
}
|
||||
|
||||
/** This row and every row scheduled on/after it within the same series — occurredOn primary, id tie-break for same-day rows. */
|
||||
private List<Transaction> seriesFrom(Transaction anchor) {
|
||||
return Transaction.list(
|
||||
"seriesId = ?1 and (occurredOn > ?2 or (occurredOn = ?2 and id >= ?3))",
|
||||
io.quarkus.panache.common.Sort.ascending("occurredOn").and("id"),
|
||||
anchor.seriesId, anchor.occurredOn, anchor.id);
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/{id}")
|
||||
@Transactional
|
||||
public void delete(@PathParam("id") Long id) {
|
||||
public void delete(@PathParam("id") Long id, @QueryParam("scope") @DefaultValue("THIS") EditScope scope) {
|
||||
Transaction txn = requireOwnedTransaction(id);
|
||||
if (txn.linkedCard != null) {
|
||||
throw new WebApplicationException("Cannot directly delete a credit card bill transaction", 400);
|
||||
}
|
||||
Account account = txn.account;
|
||||
txn.delete();
|
||||
String seriesId = txn.seriesId;
|
||||
|
||||
if (scope == EditScope.FUTURE && seriesId != null) {
|
||||
for (Transaction t : seriesFrom(txn)) {
|
||||
t.delete();
|
||||
}
|
||||
} else {
|
||||
txn.delete();
|
||||
}
|
||||
|
||||
if (seriesId != null) {
|
||||
renumberSeries(seriesId);
|
||||
}
|
||||
|
||||
billSync.recomputeAccountBalance(account);
|
||||
if (account.kind == AccountKind.CREDIT_CARD) {
|
||||
billSync.sync(account);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps seriesInfo contiguous after any delete (single-row or "this and
|
||||
* future" batch): e.g. 1/12,2/12,4/12,5/12... becomes 1/11,2/11,3/11...
|
||||
* A series collapsed down to one remaining row is no longer meaningfully
|
||||
* a series — clear its series fields so it falls back to plain
|
||||
* single-row edit/delete and drops the seriesInfo badge.
|
||||
*/
|
||||
private void renumberSeries(String seriesId) {
|
||||
List<Transaction> remaining = Transaction.findBySeries(seriesId);
|
||||
if (remaining.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (remaining.size() == 1) {
|
||||
Transaction only = remaining.get(0);
|
||||
only.seriesInfo = null;
|
||||
only.seriesId = null;
|
||||
only.seriesRepeat = null;
|
||||
return;
|
||||
}
|
||||
int total = remaining.size();
|
||||
for (int i = 0; i < total; i++) {
|
||||
remaining.get(i).seriesInfo = (i + 1) + "/" + total;
|
||||
}
|
||||
}
|
||||
|
||||
private Account requireOwnedAccount(Long accountId) {
|
||||
User user = currentUser.require();
|
||||
Account account = Account.findById(accountId);
|
||||
@@ -243,5 +333,7 @@ public class TransactionResource {
|
||||
public BigDecimal amount;
|
||||
public LocalDate occurredOn;
|
||||
public LocalDate billDueDate;
|
||||
/** THIS (default when omitted) or FUTURE. FUTURE is only honored when the target transaction has a non-null seriesId — see TransactionResource#update. */
|
||||
public EditScope scope;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE transactions ADD COLUMN series_id VARCHAR(36);
|
||||
CREATE INDEX idx_transactions_series_id ON transactions (series_id) WHERE series_id IS NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE transactions ADD COLUMN series_repeat VARCHAR(20);
|
||||
@@ -31,8 +31,9 @@ export const TransactionsApi = {
|
||||
listByAccount: (accountId) =>
|
||||
client.get(`/transactions/account/${accountId}`).then((r) => r.data),
|
||||
create: (payload) => client.post('/transactions', payload).then((r) => r.data),
|
||||
update: (id, payload) => client.put(`/transactions/${id}`, payload).then((r) => r.data),
|
||||
remove: (id) => client.delete(`/transactions/${id}`),
|
||||
update: (id, payload) => client.put(`/transactions/${id}`, payload).then((r) => r.data), // payload may include scope: 'THIS' | 'FUTURE'
|
||||
remove: (id, scope = 'THIS') =>
|
||||
client.delete(`/transactions/${id}`, { params: { scope } }).then((r) => r.data),
|
||||
};
|
||||
|
||||
export const ImportApi = {
|
||||
|
||||
@@ -319,6 +319,7 @@ export default function CardBills() {
|
||||
const [editAmount, setEditAmount] = useState('');
|
||||
const [editOccurredOn, setEditOccurredOn] = useState('');
|
||||
const [editBillDueDate, setEditBillDueDate] = useState('');
|
||||
const [editScope, setEditScope] = useState('THIS');
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, payload }) => TransactionsApi.update(id, payload),
|
||||
@@ -337,20 +338,22 @@ export default function CardBills() {
|
||||
setEditAmount(formatSignedAmount(t.amount));
|
||||
setEditOccurredOn(t.occurredOn);
|
||||
setEditBillDueDate(t.billDueDate || (selectedCard ? isoDate(billDueDateFor(selectedCard, t)) : ''));
|
||||
setEditScope('THIS');
|
||||
};
|
||||
|
||||
const handleEditSubmit = (e, id) => {
|
||||
const handleEditSubmit = (e, t) => {
|
||||
e.preventDefault();
|
||||
const naturalBill = billOptionsFor(selectedCard, editOccurredOn)[0];
|
||||
const isOverride = editBillDueDate && (!naturalBill || editBillDueDate !== isoDate(naturalBill));
|
||||
updateMutation.mutate({
|
||||
id,
|
||||
id: t.id,
|
||||
payload: {
|
||||
categoryId: editCategoryId ? Number(editCategoryId) : null,
|
||||
description: editDescription,
|
||||
amount: parseSignedAmount(editAmount),
|
||||
occurredOn: editOccurredOn,
|
||||
billDueDate: isOverride ? editBillDueDate : null,
|
||||
scope: t.seriesId ? editScope : undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -358,7 +361,7 @@ export default function CardBills() {
|
||||
const [confirmingId, setConfirmingId] = useState(null);
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: TransactionsApi.remove,
|
||||
mutationFn: ({ id, scope }) => TransactionsApi.remove(id, scope),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['transactions', selectedCard?.id] });
|
||||
@@ -626,7 +629,7 @@ export default function CardBills() {
|
||||
<div className="txn-row" key={t.id}>
|
||||
{editingId === t.id ? (
|
||||
<form
|
||||
onSubmit={(e) => handleEditSubmit(e, t.id)}
|
||||
onSubmit={(e) => handleEditSubmit(e, t)}
|
||||
className="d-flex align-items-center gap-2 flex-wrap w-100"
|
||||
>
|
||||
<select
|
||||
@@ -686,6 +689,33 @@ export default function CardBills() {
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{t.seriesId && (
|
||||
<div className="d-flex gap-3 w-100" style={{ fontSize: 12 }}>
|
||||
<label className="d-flex align-items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
name={`edit-scope-${t.id}`}
|
||||
checked={editScope === 'THIS'}
|
||||
onChange={() => setEditScope('THIS')}
|
||||
/>
|
||||
This occurrence only
|
||||
</label>
|
||||
<label className="d-flex align-items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
name={`edit-scope-${t.id}`}
|
||||
checked={editScope === 'FUTURE'}
|
||||
onChange={() => setEditScope('FUTURE')}
|
||||
/>
|
||||
This and future
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{t.seriesId && editScope === 'FUTURE' && t.seriesRepeat === 'INSTALLMENTS' && (
|
||||
<div className="w-100 text-faint" style={{ fontSize: 11.5 }}>
|
||||
Amount is re-split evenly across this and the remaining installments.
|
||||
</div>
|
||||
)}
|
||||
{updateMutation.isError && (
|
||||
<div className="w-100" style={{ fontSize: 11.5, color: 'var(--red)' }}>
|
||||
Could not save changes.
|
||||
@@ -699,13 +729,32 @@ export default function CardBills() {
|
||||
This cannot be undone.
|
||||
</span>
|
||||
<div className="d-flex gap-2 ms-auto">
|
||||
<button
|
||||
className="btn btn-red btn-sm"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => deleteMutation.mutate(t.id)}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Deleting…' : 'Delete'}
|
||||
</button>
|
||||
{t.seriesId ? (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-red btn-sm"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => deleteMutation.mutate({ id: t.id, scope: 'THIS' })}
|
||||
>
|
||||
Delete this only
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-red btn-sm"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => deleteMutation.mutate({ id: t.id, scope: 'FUTURE' })}
|
||||
>
|
||||
Delete this & future
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-red btn-sm"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => deleteMutation.mutate({ id: t.id, scope: 'THIS' })}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Deleting…' : 'Delete'}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={cancelDelete}>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@@ -305,6 +305,7 @@ export default function Transactions() {
|
||||
const [editDescription, setEditDescription] = useState('');
|
||||
const [editAmount, setEditAmount] = useState('');
|
||||
const [editOccurredOn, setEditOccurredOn] = useState('');
|
||||
const [editScope, setEditScope] = useState('THIS');
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, payload }) => TransactionsApi.update(id, payload),
|
||||
@@ -336,17 +337,19 @@ export default function Transactions() {
|
||||
setEditDescription(t.description);
|
||||
setEditAmount(formatSignedAmount(t.amount));
|
||||
setEditOccurredOn(t.occurredOn);
|
||||
setEditScope('THIS');
|
||||
};
|
||||
|
||||
const handleEditSubmit = (e, id) => {
|
||||
const handleEditSubmit = (e, t) => {
|
||||
e.preventDefault();
|
||||
updateMutation.mutate({
|
||||
id,
|
||||
id: t.id,
|
||||
payload: {
|
||||
categoryId: editCategoryId ? Number(editCategoryId) : null,
|
||||
description: editDescription,
|
||||
amount: parseSignedAmount(editAmount),
|
||||
occurredOn: editOccurredOn,
|
||||
scope: t.seriesId ? editScope : undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -354,7 +357,7 @@ export default function Transactions() {
|
||||
const [confirmingId, setConfirmingId] = useState(null);
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: TransactionsApi.remove,
|
||||
mutationFn: ({ id, scope }) => TransactionsApi.remove(id, scope),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['transactions'] });
|
||||
@@ -873,7 +876,7 @@ export default function Transactions() {
|
||||
>
|
||||
{editingId === t.id ? (
|
||||
<form
|
||||
onSubmit={(e) => handleEditSubmit(e, t.id)}
|
||||
onSubmit={(e) => handleEditSubmit(e, t)}
|
||||
className="d-flex align-items-center gap-2 flex-wrap w-100"
|
||||
>
|
||||
<select
|
||||
@@ -921,6 +924,33 @@ export default function Transactions() {
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{t.seriesId && (
|
||||
<div className="d-flex gap-3 w-100" style={{ fontSize: 12 }}>
|
||||
<label className="d-flex align-items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
name={`edit-scope-${t.id}`}
|
||||
checked={editScope === 'THIS'}
|
||||
onChange={() => setEditScope('THIS')}
|
||||
/>
|
||||
This occurrence only
|
||||
</label>
|
||||
<label className="d-flex align-items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
name={`edit-scope-${t.id}`}
|
||||
checked={editScope === 'FUTURE'}
|
||||
onChange={() => setEditScope('FUTURE')}
|
||||
/>
|
||||
This and future
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{t.seriesId && editScope === 'FUTURE' && t.seriesRepeat === 'INSTALLMENTS' && (
|
||||
<div className="w-100 text-faint" style={{ fontSize: 11.5 }}>
|
||||
Amount is re-split evenly across this and the remaining installments.
|
||||
</div>
|
||||
)}
|
||||
{updateMutation.isError && (
|
||||
<div className="w-100" style={{ fontSize: 11.5, color: 'var(--red)' }}>
|
||||
Could not save changes.
|
||||
@@ -934,13 +964,32 @@ export default function Transactions() {
|
||||
This cannot be undone.
|
||||
</span>
|
||||
<div className="d-flex gap-2 ms-auto">
|
||||
<button
|
||||
className="btn btn-red btn-sm"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => deleteMutation.mutate(t.id)}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Deleting…' : 'Delete'}
|
||||
</button>
|
||||
{t.seriesId ? (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-red btn-sm"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => deleteMutation.mutate({ id: t.id, scope: 'THIS' })}
|
||||
>
|
||||
Delete this only
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-red btn-sm"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => deleteMutation.mutate({ id: t.id, scope: 'FUTURE' })}
|
||||
>
|
||||
Delete this & future
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-red btn-sm"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => deleteMutation.mutate({ id: t.id, scope: 'THIS' })}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Deleting…' : 'Delete'}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={cancelDelete}>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user