- Add PageNumber and PageSize query parameters (default 25 per page) - Implement server-side pagination with Skip/Take - Display "Showing X-Y of Z" count in header - Add Bootstrap pagination controls with sliding page window 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
351 lines
12 KiB
C#
351 lines
12 KiB
C#
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using MoneyMap.Data;
|
|
using MoneyMap.Services;
|
|
|
|
namespace MoneyMap.Pages
|
|
{
|
|
public class ReceiptsModel : PageModel
|
|
{
|
|
private readonly MoneyMapContext _db;
|
|
private readonly IReceiptManager _receiptManager;
|
|
private readonly IReceiptAutoMapper _autoMapper;
|
|
private readonly IReceiptMatchingService _receiptMatchingService;
|
|
|
|
public ReceiptsModel(MoneyMapContext db, IReceiptManager receiptManager, IReceiptAutoMapper autoMapper, IReceiptMatchingService receiptMatchingService)
|
|
{
|
|
_db = db;
|
|
_receiptManager = receiptManager;
|
|
_autoMapper = autoMapper;
|
|
_receiptMatchingService = receiptMatchingService;
|
|
}
|
|
|
|
public List<ReceiptRow> Receipts { get; set; } = new();
|
|
public Dictionary<long, List<TransactionOption>> ReceiptTransactionMatches { get; set; } = new();
|
|
|
|
[BindProperty(SupportsGet = true)]
|
|
public int PageNumber { get; set; } = 1;
|
|
|
|
[BindProperty(SupportsGet = true)]
|
|
public int PageSize { get; set; } = 25;
|
|
|
|
public int TotalCount { get; set; }
|
|
public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
|
|
|
|
[BindProperty]
|
|
public IFormFile? UploadFile { get; set; }
|
|
|
|
[BindProperty]
|
|
public bool ConfirmDuplicateUpload { get; set; }
|
|
|
|
[TempData]
|
|
public string? Message { get; set; }
|
|
|
|
[TempData]
|
|
public bool IsSuccess { get; set; }
|
|
|
|
[TempData]
|
|
public string? PendingUploadFileName { get; set; }
|
|
|
|
[TempData]
|
|
public string? DuplicateWarningsJson { get; set; }
|
|
|
|
public List<DuplicateWarning> DuplicateWarnings { get; set; } = new();
|
|
public bool ShowDuplicateModal { get; set; } = false;
|
|
|
|
public async Task OnGetAsync()
|
|
{
|
|
await LoadReceiptsAsync();
|
|
|
|
// Show duplicate modal if warnings present
|
|
if (!string.IsNullOrWhiteSpace(DuplicateWarningsJson))
|
|
{
|
|
DuplicateWarnings = System.Text.Json.JsonSerializer.Deserialize<List<DuplicateWarning>>(DuplicateWarningsJson) ?? new();
|
|
ShowDuplicateModal = DuplicateWarnings.Any();
|
|
}
|
|
}
|
|
|
|
public async Task<IActionResult> OnPostUploadAsync()
|
|
{
|
|
if (UploadFile == null)
|
|
{
|
|
Message = "Please select a file to upload.";
|
|
IsSuccess = false;
|
|
await LoadReceiptsAsync();
|
|
return Page();
|
|
}
|
|
|
|
// If not confirmed and duplicates exist, show modal
|
|
if (!ConfirmDuplicateUpload)
|
|
{
|
|
var result = await _receiptManager.UploadUnmappedReceiptAsync(UploadFile);
|
|
|
|
if (result.IsSuccess)
|
|
{
|
|
if (result.DuplicateWarnings.Any())
|
|
{
|
|
// Store warnings and redirect to show modal
|
|
PendingUploadFileName = UploadFile.FileName;
|
|
DuplicateWarningsJson = System.Text.Json.JsonSerializer.Serialize(result.DuplicateWarnings);
|
|
|
|
// Delete the uploaded file since user needs to confirm
|
|
await _receiptManager.DeleteReceiptAsync(result.Receipt!.Id);
|
|
|
|
return RedirectToPage();
|
|
}
|
|
else
|
|
{
|
|
Message = "Receipt uploaded successfully!";
|
|
IsSuccess = true;
|
|
return RedirectToPage();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Message = result.ErrorMessage ?? "Failed to upload receipt.";
|
|
IsSuccess = false;
|
|
await LoadReceiptsAsync();
|
|
return Page();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// User confirmed, upload anyway
|
|
var result = await _receiptManager.UploadUnmappedReceiptAsync(UploadFile);
|
|
|
|
if (result.IsSuccess)
|
|
{
|
|
Message = "Receipt uploaded successfully!";
|
|
IsSuccess = true;
|
|
return RedirectToPage();
|
|
}
|
|
else
|
|
{
|
|
Message = result.ErrorMessage ?? "Failed to upload receipt.";
|
|
IsSuccess = false;
|
|
await LoadReceiptsAsync();
|
|
return Page();
|
|
}
|
|
}
|
|
}
|
|
|
|
public async Task<IActionResult> OnPostDeleteAsync(long receiptId)
|
|
{
|
|
var success = await _receiptManager.DeleteReceiptAsync(receiptId);
|
|
|
|
if (success)
|
|
{
|
|
Message = "Receipt deleted successfully.";
|
|
IsSuccess = true;
|
|
}
|
|
else
|
|
{
|
|
Message = "Failed to delete receipt.";
|
|
IsSuccess = false;
|
|
}
|
|
|
|
return RedirectToPage();
|
|
}
|
|
|
|
public async Task<IActionResult> OnPostMapToTransactionAsync(long receiptId, long transactionId)
|
|
{
|
|
if (transactionId <= 0)
|
|
{
|
|
Message = "Please select a transaction or enter a valid ID.";
|
|
IsSuccess = false;
|
|
return RedirectToPage();
|
|
}
|
|
|
|
var receipt = await _db.Receipts.FirstOrDefaultAsync(r => r.Id == receiptId);
|
|
if (receipt == null)
|
|
{
|
|
Message = $"Receipt not found (ID {receiptId}).";
|
|
IsSuccess = false;
|
|
return RedirectToPage();
|
|
}
|
|
|
|
var transactionExists = await _db.Transactions.AnyAsync(t => t.Id == transactionId);
|
|
if (!transactionExists)
|
|
{
|
|
Message = $"Transaction not found (ID {transactionId}).";
|
|
IsSuccess = false;
|
|
return RedirectToPage();
|
|
}
|
|
|
|
// Friendly duplicate check: same file already mapped to this transaction
|
|
if (!string.IsNullOrWhiteSpace(receipt.FileHashSha256))
|
|
{
|
|
var duplicateExists = await _db.Receipts.AnyAsync(r => r.Id != receiptId && r.TransactionId == transactionId && r.FileHashSha256 == receipt.FileHashSha256);
|
|
if (duplicateExists)
|
|
{
|
|
Message = "This transaction already has a receipt with the same file (duplicate prevented).";
|
|
IsSuccess = false;
|
|
return RedirectToPage();
|
|
}
|
|
}
|
|
|
|
var success = await _receiptManager.MapReceiptToTransactionAsync(receiptId, transactionId);
|
|
|
|
if (success)
|
|
{
|
|
Message = "Receipt mapped to transaction successfully.";
|
|
IsSuccess = true;
|
|
}
|
|
else
|
|
{
|
|
Message = "Failed to map receipt to transaction.";
|
|
IsSuccess = false;
|
|
}
|
|
|
|
return RedirectToPage();
|
|
}
|
|
|
|
public async Task<IActionResult> OnPostAutoMapUnmappedAsync()
|
|
{
|
|
var result = await _autoMapper.AutoMapUnmappedReceiptsAsync();
|
|
|
|
if (result.MappedCount > 0)
|
|
{
|
|
Message = $"Successfully auto-mapped {result.MappedCount} receipt(s). " +
|
|
$"{result.NoMatchCount} had no matching transaction. " +
|
|
$"{result.MultipleMatchesCount} had multiple potential matches.";
|
|
IsSuccess = true;
|
|
}
|
|
else if (result.TotalProcessed == 0)
|
|
{
|
|
Message = "No unmapped parsed receipts found to process.";
|
|
IsSuccess = false;
|
|
}
|
|
else
|
|
{
|
|
Message = $"Unable to auto-map any receipts. {result.NoMatchCount} had no matching transaction. " +
|
|
$"{result.MultipleMatchesCount} had multiple potential matches.";
|
|
IsSuccess = false;
|
|
}
|
|
|
|
return RedirectToPage();
|
|
}
|
|
|
|
public async Task<IActionResult> OnPostUnmapAsync(long receiptId)
|
|
{
|
|
var success = await _receiptManager.UnmapReceiptAsync(receiptId);
|
|
|
|
if (success)
|
|
{
|
|
Message = "Receipt unmapped successfully.";
|
|
IsSuccess = true;
|
|
}
|
|
else
|
|
{
|
|
Message = "Failed to unmap receipt.";
|
|
IsSuccess = false;
|
|
}
|
|
|
|
return RedirectToPage();
|
|
}
|
|
|
|
private async Task LoadReceiptsAsync()
|
|
{
|
|
if (PageNumber < 1) PageNumber = 1;
|
|
if (PageSize < 1) PageSize = 25;
|
|
if (PageSize > 100) PageSize = 100;
|
|
|
|
var query = _db.Receipts
|
|
.Include(r => r.Transaction)
|
|
.OrderByDescending(r => r.UploadedAtUtc);
|
|
|
|
TotalCount = await query.CountAsync();
|
|
|
|
var receipts = await query
|
|
.Skip((PageNumber - 1) * PageSize)
|
|
.Take(PageSize)
|
|
.ToListAsync();
|
|
|
|
Receipts = receipts.Select(r => new ReceiptRow
|
|
{
|
|
Id = r.Id,
|
|
FileName = r.FileName,
|
|
ContentType = r.ContentType,
|
|
FileSizeBytes = r.FileSizeBytes,
|
|
UploadedAtUtc = r.UploadedAtUtc,
|
|
TransactionId = r.TransactionId,
|
|
TransactionName = r.Transaction?.Name,
|
|
TransactionDate = r.Transaction?.Date,
|
|
TransactionAmount = r.Transaction?.Amount,
|
|
Merchant = r.Merchant,
|
|
ReceiptDate = r.ReceiptDate,
|
|
DueDate = r.DueDate,
|
|
Total = r.Total,
|
|
StoragePath = r.StoragePath
|
|
}).ToList();
|
|
|
|
// Load matching transactions for each unmapped receipt
|
|
var transactionsWithReceipts = await _receiptMatchingService.GetTransactionIdsWithReceiptsAsync();
|
|
|
|
var unmappedReceipts = Receipts.Where(r => !r.TransactionId.HasValue).ToList();
|
|
|
|
foreach (var receipt in unmappedReceipts)
|
|
{
|
|
var criteria = new ReceiptMatchCriteria
|
|
{
|
|
ReceiptDate = receipt.ReceiptDate,
|
|
DueDate = receipt.DueDate,
|
|
Total = receipt.Total,
|
|
MerchantName = receipt.Merchant,
|
|
ExcludeTransactionIds = transactionsWithReceipts
|
|
};
|
|
|
|
var matches = await _receiptMatchingService.FindMatchingTransactionsAsync(criteria);
|
|
|
|
// Convert TransactionMatch to TransactionOption
|
|
ReceiptTransactionMatches[receipt.Id] = matches.Select(m => new TransactionOption
|
|
{
|
|
Id = m.Id,
|
|
Date = m.Date,
|
|
Name = m.Name,
|
|
Amount = m.Amount,
|
|
MerchantName = m.MerchantName,
|
|
PaymentMethod = m.PaymentMethod,
|
|
IsExactAmount = m.IsExactAmount,
|
|
IsCloseAmount = m.IsCloseAmount
|
|
}).ToList();
|
|
}
|
|
}
|
|
|
|
public class ReceiptRow
|
|
{
|
|
public long Id { get; set; }
|
|
public string FileName { get; set; } = "";
|
|
public string ContentType { get; set; } = "";
|
|
public long FileSizeBytes { get; set; }
|
|
public DateTime UploadedAtUtc { get; set; }
|
|
public long? TransactionId { get; set; }
|
|
public string? TransactionName { get; set; }
|
|
public DateTime? TransactionDate { get; set; }
|
|
public decimal? TransactionAmount { get; set; }
|
|
public string? Merchant { get; set; }
|
|
public DateTime? ReceiptDate { get; set; }
|
|
public DateTime? DueDate { get; set; }
|
|
public decimal? Total { get; set; }
|
|
public string StoragePath { get; set; } = "";
|
|
}
|
|
|
|
public class TransactionOption
|
|
{
|
|
public long Id { get; set; }
|
|
public DateTime Date { get; set; }
|
|
public string Name { get; set; } = "";
|
|
public decimal Amount { get; set; }
|
|
public string? MerchantName { get; set; }
|
|
public string PaymentMethod { get; set; } = "";
|
|
public bool IsExactAmount { get; set; }
|
|
public bool IsCloseAmount { get; set; }
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|