Pagination on Transactions
This commit is contained in:
@@ -6,6 +6,13 @@
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Remove="Utility\**" />
|
||||||
|
<Content Remove="Utility\**" />
|
||||||
|
<EmbeddedResource Remove="Utility\**" />
|
||||||
|
<None Remove="Utility\**" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
||||||
<PackageReference Include="Magick.NET-Q16-AnyCPU" Version="14.8.2" />
|
<PackageReference Include="Magick.NET-Q16-AnyCPU" Version="14.8.2" />
|
||||||
@@ -20,7 +27,6 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Folder Include="Migrations\" />
|
<Folder Include="Migrations\" />
|
||||||
<Folder Include="Utility\" />
|
|
||||||
<Folder Include="wwwroot\receipts\" />
|
<Folder Include="wwwroot\receipts\" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,9 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="text-muted small">Transactions</div>
|
<div class="text-muted small">Transactions</div>
|
||||||
<div class="fs-4 fw-bold">@Model.Stats.Count</div>
|
<div class="fs-4 fw-bold">@Model.Stats.Count</div>
|
||||||
|
<div class="small text-muted">
|
||||||
|
Showing @Model.Transactions.Count on page @Model.PageNumber of @Model.TotalPages
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -182,6 +185,90 @@ else
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
@if (Model.TotalPages > 1)
|
||||||
|
{
|
||||||
|
<nav aria-label="Transaction pagination" class="mt-3">
|
||||||
|
<ul class="pagination justify-content-center">
|
||||||
|
<!-- Previous -->
|
||||||
|
<li class="page-item @(Model.PageNumber == 1 ? "disabled" : "")">
|
||||||
|
<a class="page-link"
|
||||||
|
asp-page="/Transactions"
|
||||||
|
asp-route-pageNumber="@(Model.PageNumber - 1)"
|
||||||
|
asp-route-category="@Model.Category"
|
||||||
|
asp-route-cardId="@Model.CardId"
|
||||||
|
asp-route-startDate="@Model.StartDate?.ToString("yyyy-MM-dd")"
|
||||||
|
asp-route-endDate="@Model.EndDate?.ToString("yyyy-MM-dd")">
|
||||||
|
Previous
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- First page -->
|
||||||
|
@if (Model.PageNumber > 3)
|
||||||
|
{
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link"
|
||||||
|
asp-page="/Transactions"
|
||||||
|
asp-route-pageNumber="1"
|
||||||
|
asp-route-category="@Model.Category"
|
||||||
|
asp-route-cardId="@Model.CardId"
|
||||||
|
asp-route-startDate="@Model.StartDate?.ToString("yyyy-MM-dd")"
|
||||||
|
asp-route-endDate="@Model.EndDate?.ToString("yyyy-MM-dd")">
|
||||||
|
1
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="page-item disabled"><span class="page-link">...</span></li>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Page numbers -->
|
||||||
|
@for (int i = Math.Max(1, Model.PageNumber - 2); i <= Math.Min(Model.TotalPages, Model.PageNumber + 2); i++)
|
||||||
|
{
|
||||||
|
<li class="page-item @(i == Model.PageNumber ? "active" : "")">
|
||||||
|
<a class="page-link"
|
||||||
|
asp-page="/Transactions"
|
||||||
|
asp-route-pageNumber="@i"
|
||||||
|
asp-route-category="@Model.Category"
|
||||||
|
asp-route-cardId="@Model.CardId"
|
||||||
|
asp-route-startDate="@Model.StartDate?.ToString("yyyy-MM-dd")"
|
||||||
|
asp-route-endDate="@Model.EndDate?.ToString("yyyy-MM-dd")">
|
||||||
|
@i
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Last page -->
|
||||||
|
@if (Model.PageNumber < Model.TotalPages - 2)
|
||||||
|
{
|
||||||
|
<li class="page-item disabled"><span class="page-link">...</span></li>
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link"
|
||||||
|
asp-page="/Transactions"
|
||||||
|
asp-route-pageNumber="@Model.TotalPages"
|
||||||
|
asp-route-category="@Model.Category"
|
||||||
|
asp-route-cardId="@Model.CardId"
|
||||||
|
asp-route-startDate="@Model.StartDate?.ToString("yyyy-MM-dd")"
|
||||||
|
asp-route-endDate="@Model.EndDate?.ToString("yyyy-MM-dd")">
|
||||||
|
@Model.TotalPages
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Next -->
|
||||||
|
<li class="page-item @(Model.PageNumber >= Model.TotalPages ? "disabled" : "")">
|
||||||
|
<a class="page-link"
|
||||||
|
asp-page="/Transactions"
|
||||||
|
asp-route-pageNumber="@(Model.PageNumber + 1)"
|
||||||
|
asp-route-category="@Model.Category"
|
||||||
|
asp-route-cardId="@Model.CardId"
|
||||||
|
asp-route-startDate="@Model.StartDate?.ToString("yyyy-MM-dd")"
|
||||||
|
asp-route-endDate="@Model.EndDate?.ToString("yyyy-MM-dd")">
|
||||||
|
Next
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
<script>
|
<script>
|
||||||
// Initialize Bootstrap tooltips for notes badges
|
// Initialize Bootstrap tooltips for notes badges
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ namespace MoneyMap.Pages
|
|||||||
[BindProperty(SupportsGet = true)]
|
[BindProperty(SupportsGet = true)]
|
||||||
public DateTime? EndDate { get; set; }
|
public DateTime? EndDate { get; set; }
|
||||||
|
|
||||||
|
[BindProperty(SupportsGet = true)]
|
||||||
|
public int PageNumber { get; set; } = 1;
|
||||||
|
|
||||||
|
public int PageSize { get; set; } = 50;
|
||||||
|
public int TotalPages { get; set; }
|
||||||
|
public int TotalCount { get; set; }
|
||||||
|
|
||||||
public List<TransactionRow> Transactions { get; set; } = new();
|
public List<TransactionRow> Transactions { get; set; } = new();
|
||||||
public List<string> AvailableCategories { get; set; } = new();
|
public List<string> AvailableCategories { get; set; } = new();
|
||||||
public List<Card> AvailableCards { get; set; } = new();
|
public List<Card> AvailableCards { get; set; } = new();
|
||||||
@@ -68,12 +75,32 @@ namespace MoneyMap.Pages
|
|||||||
query = query.Where(t => t.Date <= EndDate.Value);
|
query = query.Where(t => t.Date <= EndDate.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get transactions
|
// Get total count for pagination
|
||||||
|
TotalCount = await query.CountAsync();
|
||||||
|
TotalPages = (int)Math.Ceiling(TotalCount / (double)PageSize);
|
||||||
|
|
||||||
|
// Ensure page number is valid
|
||||||
|
if (PageNumber < 1) PageNumber = 1;
|
||||||
|
if (PageNumber > TotalPages && TotalPages > 0) PageNumber = TotalPages;
|
||||||
|
|
||||||
|
// Get paginated transactions
|
||||||
var transactions = await query
|
var transactions = await query
|
||||||
.OrderByDescending(t => t.Date)
|
.OrderByDescending(t => t.Date)
|
||||||
.ThenByDescending(t => t.Id)
|
.ThenByDescending(t => t.Id)
|
||||||
|
.Skip((PageNumber - 1) * PageSize)
|
||||||
|
.Take(PageSize)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
|
// Get receipt counts for the current page only
|
||||||
|
var transactionIds = transactions.Select(t => t.Id).ToList();
|
||||||
|
var receiptCounts = await _db.Receipts
|
||||||
|
.Where(r => transactionIds.Contains(r.TransactionId))
|
||||||
|
.GroupBy(r => r.TransactionId)
|
||||||
|
.Select(g => new { TransactionId = g.Key, Count = g.Count() })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var receiptCountDict = receiptCounts.ToDictionary(x => x.TransactionId, x => x.Count);
|
||||||
|
|
||||||
Transactions = transactions.Select(t => new TransactionRow
|
Transactions = transactions.Select(t => new TransactionRow
|
||||||
{
|
{
|
||||||
Id = t.Id,
|
Id = t.Id,
|
||||||
@@ -86,16 +113,17 @@ namespace MoneyMap.Pages
|
|||||||
CardLabel = t.Card != null
|
CardLabel = t.Card != null
|
||||||
? $"{t.Card.Issuer} {t.Card.Last4}"
|
? $"{t.Card.Issuer} {t.Card.Last4}"
|
||||||
: (string.IsNullOrEmpty(t.CardLast4) ? "" : $"•••• {t.CardLast4}"),
|
: (string.IsNullOrEmpty(t.CardLast4) ? "" : $"•••• {t.CardLast4}"),
|
||||||
ReceiptCount = t.Receipts?.Count ?? 0
|
ReceiptCount = receiptCountDict.ContainsKey(t.Id) ? receiptCountDict[t.Id] : 0
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
// Calculate stats for filtered results
|
// Calculate stats for filtered results (all pages, not just current)
|
||||||
|
var allFilteredTransactions = await query.ToListAsync();
|
||||||
Stats = new TransactionStats
|
Stats = new TransactionStats
|
||||||
{
|
{
|
||||||
Count = transactions.Count,
|
Count = allFilteredTransactions.Count,
|
||||||
TotalDebits = transactions.Where(t => t.Amount < 0).Sum(t => t.Amount),
|
TotalDebits = allFilteredTransactions.Where(t => t.Amount < 0).Sum(t => t.Amount),
|
||||||
TotalCredits = transactions.Where(t => t.Amount > 0).Sum(t => t.Amount),
|
TotalCredits = allFilteredTransactions.Where(t => t.Amount > 0).Sum(t => t.Amount),
|
||||||
NetAmount = transactions.Sum(t => t.Amount)
|
NetAmount = allFilteredTransactions.Sum(t => t.Amount)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get available categories for filter dropdown
|
// Get available categories for filter dropdown
|
||||||
|
|||||||
@@ -23,13 +23,22 @@ namespace MoneyMap.Services
|
|||||||
{
|
{
|
||||||
private readonly MoneyMapContext _db;
|
private readonly MoneyMapContext _db;
|
||||||
private readonly IWebHostEnvironment _environment;
|
private readonly IWebHostEnvironment _environment;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
|
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
|
||||||
private static readonly string[] AllowedExtensions = { ".jpg", ".jpeg", ".png", ".pdf", ".gif", ".heic" };
|
private static readonly string[] AllowedExtensions = { ".jpg", ".jpeg", ".png", ".pdf", ".gif", ".heic" };
|
||||||
|
|
||||||
public ReceiptManager(MoneyMapContext db, IWebHostEnvironment environment)
|
public ReceiptManager(MoneyMapContext db, IWebHostEnvironment environment, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_environment = environment;
|
_environment = environment;
|
||||||
|
_configuration = configuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetReceiptsBasePath()
|
||||||
|
{
|
||||||
|
// Get from config, default to "receipts" in wwwroot
|
||||||
|
var relativePath = _configuration["Receipts:StoragePath"] ?? "receipts";
|
||||||
|
return Path.Combine(_environment.WebRootPath, relativePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ReceiptUploadResult> UploadReceiptAsync(long transactionId, IFormFile file)
|
public async Task<ReceiptUploadResult> UploadReceiptAsync(long transactionId, IFormFile file)
|
||||||
@@ -51,9 +60,9 @@ namespace MoneyMap.Services
|
|||||||
return ReceiptUploadResult.Failure("Transaction not found.");
|
return ReceiptUploadResult.Failure("Transaction not found.");
|
||||||
|
|
||||||
// Create receipts directory if it doesn't exist
|
// Create receipts directory if it doesn't exist
|
||||||
var receiptsPath = Path.Combine(_environment.WebRootPath, "receipts");
|
var receiptsBasePath = GetReceiptsBasePath();
|
||||||
if (!Directory.Exists(receiptsPath))
|
if (!Directory.Exists(receiptsBasePath))
|
||||||
Directory.CreateDirectory(receiptsPath);
|
Directory.CreateDirectory(receiptsBasePath);
|
||||||
|
|
||||||
// Calculate SHA256 hash
|
// Calculate SHA256 hash
|
||||||
string fileHash;
|
string fileHash;
|
||||||
@@ -73,7 +82,7 @@ namespace MoneyMap.Services
|
|||||||
|
|
||||||
// Generate unique filename
|
// Generate unique filename
|
||||||
var storedFileName = $"{transactionId}_{Guid.NewGuid()}{extension}";
|
var storedFileName = $"{transactionId}_{Guid.NewGuid()}{extension}";
|
||||||
var filePath = Path.Combine(receiptsPath, storedFileName);
|
var filePath = Path.Combine(receiptsBasePath, storedFileName);
|
||||||
|
|
||||||
// Save file
|
// Save file
|
||||||
using (var fileStream = new FileStream(filePath, FileMode.Create))
|
using (var fileStream = new FileStream(filePath, FileMode.Create))
|
||||||
@@ -81,12 +90,16 @@ namespace MoneyMap.Services
|
|||||||
await file.CopyToAsync(fileStream);
|
await file.CopyToAsync(fileStream);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store relative path in database
|
||||||
|
var relativePath = _configuration["Receipts:StoragePath"] ?? "receipts";
|
||||||
|
var relativeStoragePath = $"{relativePath}/{storedFileName}";
|
||||||
|
|
||||||
// Create receipt record
|
// Create receipt record
|
||||||
var receipt = new Receipt
|
var receipt = new Receipt
|
||||||
{
|
{
|
||||||
TransactionId = transactionId,
|
TransactionId = transactionId,
|
||||||
FileName = file.FileName,
|
FileName = file.FileName,
|
||||||
StoragePath = $"receipts/{storedFileName}",
|
StoragePath = relativeStoragePath,
|
||||||
FileSizeBytes = file.Length,
|
FileSizeBytes = file.Length,
|
||||||
ContentType = file.ContentType,
|
ContentType = file.ContentType,
|
||||||
FileHashSha256 = fileHash,
|
FileHashSha256 = fileHash,
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"MoneyMapDb": "Server=barge.lan;Database=MoneyMap;User Id=moneymap;Password=Cn87oXQPj7EEkx;TrustServerCertificate=True;"
|
"MoneyMapDb": "Server=barge.lan;Database=MoneyMap;User Id=moneymap;Password=Cn87oXQPj7EEkx;TrustServerCertificate=True;"
|
||||||
},
|
},
|
||||||
|
"Receipts": {
|
||||||
|
"StoragePath": "\\\\TRUENAS\\aj\\Documents\\Receipts"
|
||||||
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
|
|||||||
Reference in New Issue
Block a user