feat(journal): edit a placed bet

Adds an edit flow to the bet journal — an Edit button per row repurposes the inline
entry form (edit mode), saving via a new UpdatePlacedBetUseCase that preserves the
original placed-at and re-grades the outcome against the result (so a changed
selection/event re-settles). A cancel affordance + an editing banner make the mode
obvious; the submit button switches to "Save changes".

- UpdatePlacedBetUseCase (loads existing for PlacedAt, validates the event, re-grades)
  + IBetJournalService.UpdateAsync + service impl; Journal page edit-mode state +
  per-row Edit button; en/ru resx.
- 3 tests: unknown-bet, unknown-event, and preserve-PlacedAt + re-grade.
This commit is contained in:
2026-05-29 13:38:38 +03:00
parent 41148a87a6
commit 1092e2a2c5
8 changed files with 275 additions and 4 deletions
+61 -4
View File
@@ -259,14 +259,28 @@
<p class="m-journal__form-error" data-test="journal-add-error">@_formError</p>
}
@if (_editingId is not null)
{
<p class="m-journal__form-hint" data-test="journal-editing-banner">@L["Journal.Editing"]</p>
}
<div class="m-journal__form-actions">
@if (_editingId is not null)
{
<button type="button"
class="m-chip m-journal__chip"
@onclick="CancelEdit"
data-test="journal-edit-cancel">
@L["Journal.Action.Cancel"]
</button>
}
<button type="button"
class="m-chip m-journal__submit"
@onclick="SubmitAsync"
disabled="@_submitting"
data-test="journal-add-submit">
<span class="m-journal__chip-glyph @(_submitting ? "is-spinning" : null)" aria-hidden="true">+</span>
<span>@L["Journal.Action.Submit"]</span>
<span class="m-journal__chip-glyph @(_submitting ? "is-spinning" : null)" aria-hidden="true">@(_editingId is null ? "+" : "✓")</span>
<span>@(_editingId is null ? L["Journal.Action.Submit"] : L["Journal.Action.SaveEdit"])</span>
</button>
</div>
</article>
@@ -359,6 +373,14 @@
}
else
{
<button type="button"
class="m-chip m-journal__chip m-journal__chip--ghost"
@onclick="@(() => BeginEdit(row))"
data-test="@($"journal-edit-{row.Id}")"
aria-label="@L["Journal.Action.EditBet"]">
<span aria-hidden="true">✎</span>
<span>@L["Journal.Action.EditBet"]</span>
</button>
<button type="button"
class="m-chip m-journal__chip m-journal__chip--ghost"
@onclick="@(() => RequestDelete(row.Id))"
@@ -702,6 +724,7 @@
private bool _resolving;
private string? _formError;
private Guid? _pendingDeleteId;
private Guid? _editingId;
private AddBetForm _form = new();
// Kelly stake-helper state — page-local (not persisted with the bet). Bankroll
@@ -851,10 +874,19 @@
var ct = _loadCts?.Token ?? CancellationToken.None;
try
{
await Service.AddAsync(_form, ct);
if (_editingId is { } editId)
{
await Service.UpdateAsync(editId, _form, ct);
Snackbar.Add(L["Journal.Edited"].Value, Severity.Success);
}
else
{
await Service.AddAsync(_form, ct);
Snackbar.Add(L["Journal.Submitted"].Value, Severity.Success);
}
_editingId = null;
_form = new AddBetForm();
_formError = null;
Snackbar.Add(L["Journal.Submitted"].Value, Severity.Success);
await LoadAsync();
}
catch (ArgumentException ex)
@@ -891,6 +923,31 @@
}
}
private void BeginEdit(BetJournalRowVm row)
{
var b = row.Bet;
_editingId = row.Id;
_pendingDeleteId = null;
_form = new AddBetForm
{
EventId = b.EventId.Value,
Type = b.Selection.Type,
Side = b.Selection.Side,
Value = b.Selection.Value?.Value,
Rate = b.Selection.Rate.Value,
Stake = b.Stake,
Notes = b.Notes,
};
_formError = null;
}
private void CancelEdit()
{
_editingId = null;
_form = new AddBetForm();
_formError = null;
}
private void RequestDelete(Guid id)
{
_pendingDeleteId = id;
@@ -424,6 +424,10 @@
<data name="Journal.Action.Refresh"><value>Refresh</value></data>
<data name="Journal.Action.Resolve"><value>Resolve pending</value></data>
<data name="Journal.Action.Submit"><value>Record bet</value></data>
<data name="Journal.Action.SaveEdit"><value>Save changes</value></data>
<data name="Journal.Action.EditBet"><value>Edit</value></data>
<data name="Journal.Edited"><value>Bet updated.</value></data>
<data name="Journal.Editing"><value>Editing an existing bet — save to apply your changes, or cancel.</value></data>
<data name="Journal.Action.Delete"><value>Delete</value></data>
<data name="Journal.Action.Confirm"><value>Confirm</value></data>
<data name="Journal.Action.Cancel"><value>Cancel</value></data>
@@ -437,6 +437,10 @@
<data name="Journal.Action.Refresh"><value>Обновить</value></data>
<data name="Journal.Action.Resolve"><value>Рассчитать ожидающие</value></data>
<data name="Journal.Action.Submit"><value>Записать</value></data>
<data name="Journal.Action.SaveEdit"><value>Сохранить изменения</value></data>
<data name="Journal.Action.EditBet"><value>Изменить</value></data>
<data name="Journal.Edited"><value>Ставка обновлена.</value></data>
<data name="Journal.Editing"><value>Редактирование ставки — сохраните изменения или отмените.</value></data>
<data name="Journal.Action.Delete"><value>Удалить</value></data>
<data name="Journal.Action.Confirm"><value>Подтвердить</value></data>
<data name="Journal.Action.Cancel"><value>Отмена</value></data>
@@ -19,6 +19,7 @@ public sealed class BetJournalService : IBetJournalService
private readonly RecordPlacedBetUseCase _record;
private readonly ResolvePendingBetsUseCase _resolve;
private readonly DeletePlacedBetUseCase _delete;
private readonly UpdatePlacedBetUseCase _update;
private readonly IEventRepository _events;
public BetJournalService(
@@ -26,12 +27,14 @@ public sealed class BetJournalService : IBetJournalService
RecordPlacedBetUseCase record,
ResolvePendingBetsUseCase resolve,
DeletePlacedBetUseCase delete,
UpdatePlacedBetUseCase update,
IEventRepository events)
{
_build = build ?? throw new ArgumentNullException(nameof(build));
_record = record ?? throw new ArgumentNullException(nameof(record));
_resolve = resolve ?? throw new ArgumentNullException(nameof(resolve));
_delete = delete ?? throw new ArgumentNullException(nameof(delete));
_update = update ?? throw new ArgumentNullException(nameof(update));
_events = events ?? throw new ArgumentNullException(nameof(events));
}
@@ -92,6 +95,29 @@ public sealed class BetJournalService : IBetJournalService
return stored.Id;
}
public async Task UpdateAsync(Guid betId, AddBetForm form, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(form);
if (!form.IsValid(out var error))
throw new ArgumentException(error ?? "Invalid form.", nameof(form));
var selection = new Bet(
scope: MatchScope.Instance,
type: form.Type,
side: form.Side,
value: form.Value is { } v ? new OddsValue(v) : null,
rate: new OddsRate(form.Rate));
await _update.ExecuteAsync(
betId,
new DomainEventId(form.EventId.Trim()),
selection,
form.Stake,
form.Notes,
ct).ConfigureAwait(false);
}
public Task DeleteAsync(Guid betId, CancellationToken ct) =>
_delete.ExecuteAsync(betId, ct);
@@ -18,6 +18,14 @@ public interface IBetJournalService
/// <exception cref="ArgumentException">Form fails its own validation.</exception>
Task<Guid> AddAsync(AddBetForm form, CancellationToken ct);
/// <summary>
/// Updates an existing bet's selection / stake / notes, preserving its original
/// placed-at and re-grading against the event result.
/// </summary>
/// <exception cref="ArgumentException">Form fails its own validation.</exception>
/// <exception cref="InvalidOperationException">The bet id or its event is unknown.</exception>
Task UpdateAsync(Guid betId, AddBetForm form, CancellationToken ct);
/// <summary>Removes a bet by id. No-op when the id is unknown.</summary>
Task DeleteAsync(Guid betId, CancellationToken ct);