Update UI for account-card relationship and transfers
- Updated EditTransaction to handle account selection and card filtering - Added Transfers link to navigation menu - Updated Transactions page to properly display account labels - Refined Upload preview UI for account/card selection These changes support the new account-centric model where every transaction belongs to an account and optionally uses a card. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -61,10 +61,16 @@
|
|||||||
<div class="form-control-plaintext">@Model.Transaction.Memo</div>
|
<div class="form-control-plaintext">@Model.Transaction.Memo</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="row mb-3">
|
||||||
<label class="form-label fw-bold">Card</label>
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-bold">Payment Method</label>
|
||||||
<div class="form-control-plaintext">@Model.Transaction.CardLabel</div>
|
<div class="form-control-plaintext">@Model.Transaction.CardLabel</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-bold">Account</label>
|
||||||
|
<div class="form-control-plaintext">@Model.Transaction.AccountLabel</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<hr class="my-4" />
|
<hr class="my-4" />
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ namespace MoneyMap.Pages
|
|||||||
{
|
{
|
||||||
var transaction = await _db.Transactions
|
var transaction = await _db.Transactions
|
||||||
.Include(t => t.Card)
|
.Include(t => t.Card)
|
||||||
|
.ThenInclude(c => c!.Account)
|
||||||
|
.Include(t => t.Account)
|
||||||
|
.Include(t => t.TransferToAccount)
|
||||||
.Include(t => t.Receipts)
|
.Include(t => t.Receipts)
|
||||||
.ThenInclude(r => r.LineItems)
|
.ThenInclude(r => r.LineItems)
|
||||||
.FirstOrDefaultAsync(t => t.Id == id);
|
.FirstOrDefaultAsync(t => t.Id == id);
|
||||||
@@ -62,7 +65,8 @@ namespace MoneyMap.Pages
|
|||||||
Amount = transaction.Amount,
|
Amount = transaction.Amount,
|
||||||
Category = transaction.Category ?? "",
|
Category = transaction.Category ?? "",
|
||||||
Notes = transaction.Notes ?? "",
|
Notes = transaction.Notes ?? "",
|
||||||
CardLabel = transaction.PaymentMethodLabel
|
CardLabel = transaction.PaymentMethodLabel,
|
||||||
|
AccountLabel = transaction.Card?.Account?.DisplayLabel ?? transaction.Account?.DisplayLabel ?? "None"
|
||||||
};
|
};
|
||||||
|
|
||||||
Receipts = transaction.Receipts?.Select(r => new ReceiptWithItems
|
Receipts = transaction.Receipts?.Select(r => new ReceiptWithItems
|
||||||
@@ -207,6 +211,7 @@ namespace MoneyMap.Pages
|
|||||||
public string Category { get; set; } = "";
|
public string Category { get; set; } = "";
|
||||||
public string? Notes { get; set; } = "";
|
public string? Notes { get; set; } = "";
|
||||||
public string CardLabel { get; set; } = "";
|
public string CardLabel { get; set; } = "";
|
||||||
|
public string AccountLabel { get; set; } = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ReceiptWithItems
|
public class ReceiptWithItems
|
||||||
|
|||||||
@@ -40,12 +40,15 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link text-dark" asp-page="/Recategorize">Recategorize</a>
|
<a class="nav-link text-dark" asp-page="/Recategorize">Recategorize</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link text-dark" asp-page="/CreateTransfer">Transfer</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<div class="container">
|
<div class="@(ViewData["FullWidth"] is true ? "container-fluid px-3" : "container")">
|
||||||
<main role="main" class="pb-3">
|
<main role="main" class="pb-3">
|
||||||
@RenderBody()
|
@RenderBody()
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -45,7 +45,12 @@ namespace MoneyMap.Pages
|
|||||||
|
|
||||||
public async Task OnGetAsync()
|
public async Task OnGetAsync()
|
||||||
{
|
{
|
||||||
var query = _db.Transactions.Include(t => t.Card).AsQueryable();
|
var query = _db.Transactions
|
||||||
|
.Include(t => t.Card)
|
||||||
|
.ThenInclude(c => c!.Account)
|
||||||
|
.Include(t => t.Account)
|
||||||
|
.Include(t => t.TransferToAccount)
|
||||||
|
.AsQueryable();
|
||||||
|
|
||||||
// Apply filters
|
// Apply filters
|
||||||
if (!string.IsNullOrWhiteSpace(Category))
|
if (!string.IsNullOrWhiteSpace(Category))
|
||||||
@@ -111,6 +116,7 @@ namespace MoneyMap.Pages
|
|||||||
Category = t.Category ?? "",
|
Category = t.Category ?? "",
|
||||||
Notes = t.Notes ?? "",
|
Notes = t.Notes ?? "",
|
||||||
CardLabel = t.PaymentMethodLabel,
|
CardLabel = t.PaymentMethodLabel,
|
||||||
|
AccountLabel = t.Card?.Account?.DisplayLabel ?? t.Account?.DisplayLabel ?? "None",
|
||||||
ReceiptCount = receiptCountDict.ContainsKey(t.Id) ? receiptCountDict[t.Id] : 0
|
ReceiptCount = receiptCountDict.ContainsKey(t.Id) ? receiptCountDict[t.Id] : 0
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
@@ -148,6 +154,7 @@ namespace MoneyMap.Pages
|
|||||||
public string Category { get; set; } = "";
|
public string Category { get; set; } = "";
|
||||||
public string Notes { get; set; } = "";
|
public string Notes { get; set; } = "";
|
||||||
public string CardLabel { get; set; } = "";
|
public string CardLabel { get; set; } = "";
|
||||||
|
public string AccountLabel { get; set; } = "";
|
||||||
public int ReceiptCount { get; set; }
|
public int ReceiptCount { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,155 @@
|
|||||||
@model MoneyMap.Pages.UploadModel
|
@model MoneyMap.Pages.UploadModel
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Upload Transactions";
|
ViewData["Title"] = "Upload Transactions";
|
||||||
|
ViewData["FullWidth"] = Model.PreviewTransactions.Any();
|
||||||
}
|
}
|
||||||
|
|
||||||
<h2>Upload Transactions</h2>
|
<h2>Upload Transactions</h2>
|
||||||
|
|
||||||
|
@if (Model.PreviewTransactions.Any())
|
||||||
|
{
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<strong>Preview:</strong> Review the transactions below before importing. Duplicates are marked in gray and will be skipped.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" asp-page-handler="Confirm" id="confirmForm" onsubmit="return prepareFormData()">
|
||||||
|
<input type="hidden" name="selectedIndices" id="selectedIndices" />
|
||||||
|
<input type="hidden" name="paymentData" id="paymentData" />
|
||||||
|
|
||||||
|
<!-- Account Selection for All Transactions -->
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label fw-bold mb-0">Account for all transactions:</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<select id="globalAccountId" class="form-select" onchange="updateAllTransactionAccounts()">
|
||||||
|
<option value="">-- choose account --</option>
|
||||||
|
@foreach (var a in Model.Accounts)
|
||||||
|
{
|
||||||
|
var isSelected = Model.PreviewTransactions.FirstOrDefault()?.Transaction.AccountId == a.Id;
|
||||||
|
<option value="@a.Id" selected="@isSelected">@a.DisplayLabel</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<small class="text-muted">All transactions in this file belong to this account</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<strong>Transaction Preview (<span id="selectedCount">@Model.PreviewTransactions.Count(p => !p.IsDuplicate)</span> selected, @Model.PreviewTransactions.Count(p => p.IsDuplicate) duplicates)</strong>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive" style="max-height: 500px; overflow-y: auto;">
|
||||||
|
<table class="table table-sm table-hover mb-0">
|
||||||
|
<thead class="sticky-top bg-white">
|
||||||
|
<tr>
|
||||||
|
<th style="width: 50px;">
|
||||||
|
<input type="checkbox" id="selectAll" class="form-check-input" onchange="toggleAllCheckboxes()" checked />
|
||||||
|
</th>
|
||||||
|
<th style="width: 100px;">Date</th>
|
||||||
|
<th>Merchant</th>
|
||||||
|
<th>Memo</th>
|
||||||
|
<th style="width: 100px;" class="text-end">Amount</th>
|
||||||
|
<th style="width: 150px;">Category</th>
|
||||||
|
<th style="width: 180px;">Card Used (optional)</th>
|
||||||
|
<th style="width: 80px;">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (int i = 0; i < Model.PreviewTransactions.Count; i++)
|
||||||
|
{
|
||||||
|
var preview = Model.PreviewTransactions[i];
|
||||||
|
<tr class="@(preview.IsDuplicate ? "table-secondary text-muted" : "")" data-index="@i">
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" data-index="@i"
|
||||||
|
class="form-check-input transaction-checkbox"
|
||||||
|
checked="@(!preview.IsDuplicate)"
|
||||||
|
onchange="updateSelectedCount()" />
|
||||||
|
</td>
|
||||||
|
<td>@preview.Transaction.Date.ToString("MM/dd/yy")</td>
|
||||||
|
<td>@preview.Transaction.Name</td>
|
||||||
|
<td class="text-truncate" style="max-width: 200px;" title="@preview.Transaction.Memo">
|
||||||
|
@preview.Transaction.Memo
|
||||||
|
</td>
|
||||||
|
<td class="text-end @(preview.Transaction.Amount >= 0 ? "text-success" : "text-danger")">
|
||||||
|
@preview.Transaction.Amount.ToString("C")
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="text"
|
||||||
|
data-index="@i"
|
||||||
|
class="form-control form-control-sm category-input"
|
||||||
|
value="@preview.Transaction.Category"
|
||||||
|
placeholder="(uncategorized)"
|
||||||
|
list="categoryList" />
|
||||||
|
@if (!string.IsNullOrWhiteSpace(preview.SuggestedCategory))
|
||||||
|
{
|
||||||
|
<small class="badge bg-info" title="Auto-suggested">Auto</small>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select data-index="@i" class="form-select form-select-sm card-select">
|
||||||
|
<option value="">-- none / direct debit --</option>
|
||||||
|
@foreach (var c in Model.Cards)
|
||||||
|
{
|
||||||
|
<option value="@c.Id" selected="@(preview.Transaction.CardId == c.Id)">@c.DisplayLabel</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (preview.IsDuplicate)
|
||||||
|
{
|
||||||
|
<span class="badge bg-warning">Duplicate</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="badge bg-success">New</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Category autocomplete datalist -->
|
||||||
|
<datalist id="categoryList">
|
||||||
|
@foreach (var cat in Model.PreviewTransactions.Select(p => p.Transaction.Category).Where(c => !string.IsNullOrWhiteSpace(c)).Distinct().OrderBy(c => c))
|
||||||
|
{
|
||||||
|
<option value="@cat">@cat</option>
|
||||||
|
}
|
||||||
|
</datalist>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-success btn-lg">
|
||||||
|
<strong>Confirm Import</strong> (<span id="selectedCountButton">@Model.PreviewTransactions.Count(p => !p.IsDuplicate)</span> transactions)
|
||||||
|
</button>
|
||||||
|
<a asp-page="/Upload" class="btn btn-secondary btn-lg">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
else if (Model.Result != null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<h4>Import Complete!</h4>
|
||||||
|
<p>
|
||||||
|
<strong>@Model.Result.Total</strong> total rows processed<br />
|
||||||
|
<strong class="text-success">@Model.Result.Inserted</strong> new transactions imported<br />
|
||||||
|
<strong class="text-warning">@Model.Result.Skipped</strong> duplicates skipped
|
||||||
|
</p>
|
||||||
|
<a asp-page="/Transactions" class="btn btn-primary">View Transactions</a>
|
||||||
|
<a asp-page="/Upload" class="btn btn-outline-secondary">Upload More</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
<form method="post" enctype="multipart/form-data" class="vstack gap-3">
|
<form method="post" enctype="multipart/form-data" class="vstack gap-3">
|
||||||
@Html.AntiForgeryToken()
|
@Html.AntiForgeryToken()
|
||||||
|
|
||||||
@@ -17,32 +162,136 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<fieldset class="border rounded p-3">
|
<fieldset class="border rounded p-3">
|
||||||
<legend class="float-none w-auto fs-6">Card association</legend>
|
<legend class="float-none w-auto fs-6">Payment Method Assignment</legend>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-bold">Mode</label>
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input class="form-check-input" type="radio" asp-for="CardMode" value="Auto" checked />
|
<input class="form-check-input" type="radio" asp-for="PaymentMode" value="Auto" id="modeAuto" onchange="togglePaymentSelection()" checked />
|
||||||
<label class="form-check-label">Automatically determine</label>
|
<label class="form-check-label" for="modeAuto">
|
||||||
|
Automatically determine from CSV
|
||||||
|
</label>
|
||||||
|
<div class="form-text small">Extracts card info from memo or filename</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input class="form-check-input" type="radio" asp-for="CardMode" value="Manual" />
|
<input class="form-check-input" type="radio" asp-for="PaymentMode" value="Card" id="modeCard" onchange="togglePaymentSelection()" />
|
||||||
<label class="form-check-label">Select from list</label>
|
<label class="form-check-label" for="modeCard">
|
||||||
|
Assign all to a specific card
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div id="cardSelectRow" class="mt-2">
|
<div class="form-check">
|
||||||
<label class="form-label">Card</label>
|
<input class="form-check-input" type="radio" asp-for="PaymentMode" value="Account" id="modeAccount" onchange="togglePaymentSelection()" />
|
||||||
|
<label class="form-check-label" for="modeAccount">
|
||||||
|
Assign all to a bank account
|
||||||
|
</label>
|
||||||
|
<div class="form-text small">For checking/savings account transactions</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="cardSelectRow" class="mt-3" style="display: none;">
|
||||||
|
<label class="form-label">Select Card</label>
|
||||||
<select asp-for="SelectedCardId" class="form-select">
|
<select asp-for="SelectedCardId" class="form-select">
|
||||||
<option value="">-- choose card --</option>
|
<option value="">-- choose card --</option>
|
||||||
@foreach (var c in Model.Cards)
|
@foreach (var c in Model.Cards)
|
||||||
{
|
{
|
||||||
<option value="@c.Id">@c.Issuer @c.Last4 (@c.Owner)</option>
|
<option value="@c.Id">@c.DisplayLabel</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
<span asp-validation-for="SelectedCardId" class="text-danger"></span>
|
<span asp-validation-for="SelectedCardId" class="text-danger"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="accountSelectRow" class="mt-3" style="display: none;">
|
||||||
|
<label class="form-label">Select Account</label>
|
||||||
|
<select asp-for="SelectedAccountId" class="form-select">
|
||||||
|
<option value="">-- choose account --</option>
|
||||||
|
@foreach (var a in Model.Accounts)
|
||||||
|
{
|
||||||
|
<option value="@a.Id">@a.DisplayLabel</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<span asp-validation-for="SelectedAccountId" class="text-danger"></span>
|
||||||
|
@if (!Model.Accounts.Any())
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning mt-2 mb-0">
|
||||||
|
No accounts available. <a asp-page="/EditAccount">Create an account first</a>.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">Upload</button>
|
<button type="submit" class="btn btn-primary">Preview Transactions</button>
|
||||||
<div asp-validation-summary="All" class="text-danger"></div>
|
<div asp-validation-summary="All" class="text-danger"></div>
|
||||||
</form>
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
<partial name="_ValidationScriptsPartial" />
|
<partial name="_ValidationScriptsPartial" />
|
||||||
|
<script>
|
||||||
|
function togglePaymentSelection() {
|
||||||
|
const mode = document.querySelector('input[name="PaymentMode"]:checked').value;
|
||||||
|
const cardRow = document.getElementById('cardSelectRow');
|
||||||
|
const accountRow = document.getElementById('accountSelectRow');
|
||||||
|
|
||||||
|
cardRow.style.display = mode === 'Card' ? 'block' : 'none';
|
||||||
|
accountRow.style.display = mode === 'Account' ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAllCheckboxes() {
|
||||||
|
const selectAll = document.getElementById('selectAll');
|
||||||
|
const checkboxes = document.querySelectorAll('.transaction-checkbox');
|
||||||
|
checkboxes.forEach(cb => cb.checked = selectAll.checked);
|
||||||
|
updateSelectedCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectedCount() {
|
||||||
|
const checkboxes = document.querySelectorAll('.transaction-checkbox:checked');
|
||||||
|
const count = checkboxes.length;
|
||||||
|
document.getElementById('selectedCount').textContent = count;
|
||||||
|
document.getElementById('selectedCountButton').textContent = count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAllTransactionAccounts() {
|
||||||
|
// Called when the global account dropdown changes
|
||||||
|
// No action needed - we'll read the global account on form submit
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareFormData() {
|
||||||
|
// Collect selected indices
|
||||||
|
const selectedIndices = [];
|
||||||
|
document.querySelectorAll('.transaction-checkbox:checked').forEach(cb => {
|
||||||
|
selectedIndices.push(cb.getAttribute('data-index'));
|
||||||
|
});
|
||||||
|
document.getElementById('selectedIndices').value = selectedIndices.join(',');
|
||||||
|
|
||||||
|
// Get the global account ID
|
||||||
|
const globalAccountId = document.getElementById('globalAccountId').value;
|
||||||
|
if (!globalAccountId) {
|
||||||
|
alert('Please select an account for all transactions');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect payment data (account + optional card + category per transaction)
|
||||||
|
const paymentData = {};
|
||||||
|
document.querySelectorAll('.card-select').forEach((select) => {
|
||||||
|
const index = select.getAttribute('data-index');
|
||||||
|
const cardId = select.value ? parseInt(select.value) : null;
|
||||||
|
|
||||||
|
// Get category for this transaction
|
||||||
|
const categoryInput = document.querySelector(`.category-input[data-index="${index}"]`);
|
||||||
|
const category = categoryInput ? categoryInput.value.trim() : '';
|
||||||
|
|
||||||
|
paymentData[index] = {
|
||||||
|
AccountId: parseInt(globalAccountId),
|
||||||
|
CardId: cardId,
|
||||||
|
Category: category
|
||||||
|
};
|
||||||
|
});
|
||||||
|
document.getElementById('paymentData').value = JSON.stringify(paymentData);
|
||||||
|
|
||||||
|
return true; // Allow form submission
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', togglePaymentSelection);
|
||||||
|
</script>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user