Feature: Add AICategorizePreview page for AI transaction categorization
Add new page that allows users to: - Generate AI categorization suggestions for uncategorized transactions - Review proposals with confidence levels before applying - Select specific transactions from Transactions page for AI review - Choose to create mapping rules for high-confidence matches - Support both OpenAI and LlamaCpp providers with model selection Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
231
MoneyMap/Pages/AICategorizePreview.cshtml
Normal file
231
MoneyMap/Pages/AICategorizePreview.cshtml
Normal file
@@ -0,0 +1,231 @@
|
||||
@page
|
||||
@model MoneyMap.Pages.AICategorizePreviewModel
|
||||
@{
|
||||
ViewData["Title"] = "AI Categorization Preview";
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2>AI Categorization Preview</h2>
|
||||
<div>
|
||||
<a asp-page="/Recategorize" class="btn btn-outline-secondary">Back to Recategorize</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
@Model.ErrorMessage
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!Model.Proposals.Any())
|
||||
{
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header">
|
||||
<strong>Generate AI Suggestions</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (Model.TransactionIds != null && Model.TransactionIds.Length > 0)
|
||||
{
|
||||
<p>Generate AI categorization suggestions for <strong>@Model.SelectedTransactionCount selected transaction(s)</strong>. You can review and approve them before applying.</p>
|
||||
|
||||
<form method="post" asp-page-handler="GenerateForIds"
|
||||
onsubmit="this.querySelector('button[type=submit]').disabled = true; this.querySelector('button[type=submit]').innerHTML = '<span class=\'spinner-border spinner-border-sm me-2\'></span>Analyzing transactions...';">
|
||||
@foreach (var id in Model.TransactionIds!)
|
||||
{
|
||||
<input type="hidden" name="transactionIds" value="@id" />
|
||||
}
|
||||
@if (Model.AIProvider.Equals("LlamaCpp", StringComparison.OrdinalIgnoreCase) && Model.AvailableModels.Any())
|
||||
{
|
||||
<div class="mb-3" style="max-width: 400px;">
|
||||
<label for="model" class="form-label">Model</label>
|
||||
<select name="model" id="model" class="form-select">
|
||||
@foreach (var m in Model.AvailableModels)
|
||||
{
|
||||
var isSelected = m.Id == Model.SelectedModel;
|
||||
<option value="@m.Id" selected="@isSelected">
|
||||
@(m.IsLoaded ? "● " : "○ ")@m.Id
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
<span style="color: #28a745;">●</span> Loaded
|
||||
<span class="ms-2" style="color: #6c757d;">○</span> Not loaded
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Generate Suggestions for @Model.SelectedTransactionCount Transaction(s)
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p>Generate AI categorization suggestions for uncategorized transactions. You can review and approve them before applying.</p>
|
||||
|
||||
<form method="post" asp-page-handler="Generate"
|
||||
onsubmit="this.querySelector('button[type=submit]').disabled = true; this.querySelector('button[type=submit]').innerHTML = '<span class=\'spinner-border spinner-border-sm me-2\'></span>Analyzing transactions...';">
|
||||
@if (Model.AIProvider.Equals("LlamaCpp", StringComparison.OrdinalIgnoreCase) && Model.AvailableModels.Any())
|
||||
{
|
||||
<div class="mb-3" style="max-width: 400px;">
|
||||
<label for="model" class="form-label">Model</label>
|
||||
<select name="model" id="model" class="form-select">
|
||||
@foreach (var m in Model.AvailableModels)
|
||||
{
|
||||
var isSelected = m.Id == Model.SelectedModel;
|
||||
<option value="@m.Id" selected="@isSelected">
|
||||
@(m.IsLoaded ? "● " : "○ ")@m.Id
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
<span style="color: #28a745;">●</span> Loaded
|
||||
<span class="ms-2" style="color: #6c757d;">○</span> Not loaded
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Generate Suggestions (up to 50 uncategorized)
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<form method="post" asp-page-handler="Apply">
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>Review Proposals (@Model.Proposals.Count suggestions)</strong>
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectAll(true)">Select All</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectAll(false)">Deselect All</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 40px;">
|
||||
<input type="checkbox" class="form-check-input" id="selectAllCheckbox" onchange="selectAll(this.checked)" checked>
|
||||
</th>
|
||||
<th>Transaction</th>
|
||||
<th>Current</th>
|
||||
<th>Proposed</th>
|
||||
<th>Merchant</th>
|
||||
<th style="width: 100px;">Confidence</th>
|
||||
<th style="width: 100px;">Create Rule</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var proposal in Model.Proposals)
|
||||
{
|
||||
var confidenceClass = proposal.Confidence >= 0.8m ? "bg-success" :
|
||||
proposal.Confidence >= 0.6m ? "bg-warning text-dark" : "bg-secondary";
|
||||
<tr>
|
||||
<td>
|
||||
<input type="checkbox" class="form-check-input proposal-checkbox"
|
||||
name="selectedIds" value="@proposal.TransactionId" checked>
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-bold">@proposal.TransactionName</div>
|
||||
@if (!string.IsNullOrWhiteSpace(proposal.TransactionMemo))
|
||||
{
|
||||
<small class="text-muted">@proposal.TransactionMemo</small>
|
||||
}
|
||||
<div class="small text-muted">
|
||||
@proposal.TransactionDate.ToString("yyyy-MM-dd") | @proposal.TransactionAmount.ToString("C")
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@if (!string.IsNullOrWhiteSpace(proposal.CurrentCategory))
|
||||
{
|
||||
<span class="badge bg-secondary">@proposal.CurrentCategory</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted small">(none)</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-primary">@proposal.ProposedCategory</span>
|
||||
@if (!string.IsNullOrWhiteSpace(proposal.Reasoning))
|
||||
{
|
||||
<div class="small text-muted mt-1" title="@proposal.Reasoning">
|
||||
@(proposal.Reasoning.Length > 60 ? proposal.Reasoning.Substring(0, 60) + "..." : proposal.Reasoning)
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (!string.IsNullOrWhiteSpace(proposal.ProposedMerchant))
|
||||
{
|
||||
<div>@proposal.ProposedMerchant</div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(proposal.ProposedPattern))
|
||||
{
|
||||
<code class="small">@proposal.ProposedPattern</code>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge @confidenceClass">@proposal.Confidence.ToString("P0")</span>
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" class="form-check-input"
|
||||
name="createRules" value="@proposal.TransactionId"
|
||||
@(proposal.CreateRule ? "checked" : "")
|
||||
title="Create a mapping rule for this pattern">
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between">
|
||||
<form method="post" asp-page-handler="Cancel" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-secondary">Cancel</button>
|
||||
</form>
|
||||
<button type="submit" class="btn btn-success">
|
||||
Apply Selected Categorizations
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header">
|
||||
<strong>Legend</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>Confidence Levels</h6>
|
||||
<ul class="list-unstyled mb-0">
|
||||
<li><span class="badge bg-success">80%+</span> High confidence - likely correct</li>
|
||||
<li><span class="badge bg-warning text-dark">60-79%</span> Medium confidence - review recommended</li>
|
||||
<li><span class="badge bg-secondary"><60%</span> Low confidence - verify carefully</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>Create Rule</h6>
|
||||
<p class="small text-muted mb-0">
|
||||
When checked, a category mapping rule will be created using the proposed pattern.
|
||||
Future transactions matching this pattern will be automatically categorized.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
function selectAll(checked) {
|
||||
document.querySelectorAll('.proposal-checkbox').forEach(cb => cb.checked = checked);
|
||||
document.getElementById('selectAllCheckbox').checked = checked;
|
||||
}
|
||||
</script>
|
||||
}
|
||||
321
MoneyMap/Pages/AICategorizePreview.cshtml.cs
Normal file
321
MoneyMap/Pages/AICategorizePreview.cshtml.cs
Normal file
@@ -0,0 +1,321 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
using MoneyMap.Services;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MoneyMap.Pages
|
||||
{
|
||||
public class AICategorizePreviewModel : PageModel
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
private readonly ITransactionAICategorizer _aiCategorizer;
|
||||
private readonly LlamaCppVisionClient _llamaClient;
|
||||
private readonly IConfiguration _config;
|
||||
|
||||
public AICategorizePreviewModel(
|
||||
MoneyMapContext db,
|
||||
ITransactionAICategorizer aiCategorizer,
|
||||
LlamaCppVisionClient llamaClient,
|
||||
IConfiguration config)
|
||||
{
|
||||
_db = db;
|
||||
_aiCategorizer = aiCategorizer;
|
||||
_llamaClient = llamaClient;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public List<ProposalViewModel> Proposals { get; set; } = new();
|
||||
public string ModelUsed { get; set; } = "";
|
||||
public string AIProvider => _config["AI:CategorizationProvider"] ?? "OpenAI";
|
||||
public List<LlamaCppModel> AvailableModels { get; set; } = new();
|
||||
public string SelectedModel => _config["AI:CategorizationModel"] ?? "qwen2.5-coder-32b-instruct-q6_k";
|
||||
|
||||
[TempData]
|
||||
public string? StoredTransactionIds { get; set; }
|
||||
|
||||
public long[]? TransactionIds { get; set; }
|
||||
|
||||
[TempData]
|
||||
public string? ProposalsJson { get; set; }
|
||||
|
||||
[TempData]
|
||||
public string? SuccessMessage { get; set; }
|
||||
|
||||
[TempData]
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
public int SelectedTransactionCount { get; set; }
|
||||
|
||||
public async Task<IActionResult> OnGetAsync()
|
||||
{
|
||||
// Load models for the dropdown
|
||||
if (AIProvider.Equals("LlamaCpp", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AvailableModels = await _llamaClient.GetAvailableModelsAsync();
|
||||
}
|
||||
|
||||
// Load transaction IDs from TempData if available
|
||||
if (!string.IsNullOrEmpty(StoredTransactionIds))
|
||||
{
|
||||
TransactionIds = JsonSerializer.Deserialize<long[]>(StoredTransactionIds);
|
||||
// Keep in TempData for the generate form
|
||||
StoredTransactionIds = JsonSerializer.Serialize(TransactionIds);
|
||||
}
|
||||
|
||||
// Count selected transactions if IDs provided
|
||||
if (TransactionIds != null && TransactionIds.Length > 0)
|
||||
{
|
||||
SelectedTransactionCount = await _db.Transactions
|
||||
.CountAsync(t => TransactionIds.Contains(t.Id));
|
||||
}
|
||||
|
||||
// Check if we have proposals from TempData
|
||||
if (!string.IsNullOrEmpty(ProposalsJson))
|
||||
{
|
||||
var storedProposals = JsonSerializer.Deserialize<List<StoredProposal>>(ProposalsJson);
|
||||
if (storedProposals != null)
|
||||
{
|
||||
await LoadProposalViewModels(storedProposals);
|
||||
}
|
||||
}
|
||||
|
||||
return Page();
|
||||
}
|
||||
|
||||
public IActionResult OnPostStoreIds(long[] transactionIds)
|
||||
{
|
||||
if (transactionIds == null || transactionIds.Length == 0)
|
||||
{
|
||||
return RedirectToPage("/Transactions");
|
||||
}
|
||||
|
||||
StoredTransactionIds = JsonSerializer.Serialize(transactionIds);
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostGenerateAsync(string? model)
|
||||
{
|
||||
var uncategorized = await _db.Transactions
|
||||
.Include(t => t.Card)
|
||||
.Include(t => t.Account)
|
||||
.Include(t => t.Merchant)
|
||||
.Include(t => t.TransferToAccount)
|
||||
.Where(t => string.IsNullOrWhiteSpace(t.Category))
|
||||
.OrderByDescending(t => t.Date)
|
||||
.Take(50)
|
||||
.ToListAsync();
|
||||
|
||||
if (uncategorized.Count == 0)
|
||||
{
|
||||
ErrorMessage = "No uncategorized transactions to process.";
|
||||
return RedirectToPage("/Recategorize");
|
||||
}
|
||||
|
||||
var proposals = await _aiCategorizer.ProposeBatchCategorizationAsync(uncategorized, model);
|
||||
|
||||
if (proposals.Count == 0)
|
||||
{
|
||||
ErrorMessage = "AI could not generate any categorization proposals.";
|
||||
return RedirectToPage("/Recategorize");
|
||||
}
|
||||
|
||||
// Store proposals in TempData
|
||||
var storedProposals = proposals.Select(p => new StoredProposal
|
||||
{
|
||||
TransactionId = p.TransactionId,
|
||||
Category = p.Category,
|
||||
CanonicalMerchant = p.CanonicalMerchant,
|
||||
Pattern = p.Pattern,
|
||||
Priority = p.Priority,
|
||||
Confidence = p.Confidence,
|
||||
Reasoning = p.Reasoning,
|
||||
CreateRule = p.CreateRule
|
||||
}).ToList();
|
||||
|
||||
ProposalsJson = JsonSerializer.Serialize(storedProposals);
|
||||
ModelUsed = model ?? SelectedModel;
|
||||
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostGenerateForIdsAsync(long[]? transactionIds, string? model)
|
||||
{
|
||||
// Try to get IDs from form first, then from TempData
|
||||
if ((transactionIds == null || transactionIds.Length == 0) && !string.IsNullOrEmpty(StoredTransactionIds))
|
||||
{
|
||||
transactionIds = JsonSerializer.Deserialize<long[]>(StoredTransactionIds);
|
||||
}
|
||||
|
||||
if (transactionIds == null || transactionIds.Length == 0)
|
||||
{
|
||||
ErrorMessage = "No transactions selected.";
|
||||
return RedirectToPage("/Transactions");
|
||||
}
|
||||
|
||||
var transactions = await _db.Transactions
|
||||
.Include(t => t.Card)
|
||||
.Include(t => t.Account)
|
||||
.Include(t => t.Merchant)
|
||||
.Include(t => t.TransferToAccount)
|
||||
.Where(t => transactionIds.Contains(t.Id))
|
||||
.OrderByDescending(t => t.Date)
|
||||
.Take(50)
|
||||
.ToListAsync();
|
||||
|
||||
if (transactions.Count == 0)
|
||||
{
|
||||
ErrorMessage = "Selected transactions not found.";
|
||||
return RedirectToPage("/Transactions");
|
||||
}
|
||||
|
||||
var proposals = await _aiCategorizer.ProposeBatchCategorizationAsync(transactions, model);
|
||||
|
||||
if (proposals.Count == 0)
|
||||
{
|
||||
ErrorMessage = "AI could not generate any categorization proposals.";
|
||||
return RedirectToPage("/Transactions");
|
||||
}
|
||||
|
||||
// Store proposals in TempData
|
||||
var storedProposals = proposals.Select(p => new StoredProposal
|
||||
{
|
||||
TransactionId = p.TransactionId,
|
||||
Category = p.Category,
|
||||
CanonicalMerchant = p.CanonicalMerchant,
|
||||
Pattern = p.Pattern,
|
||||
Priority = p.Priority,
|
||||
Confidence = p.Confidence,
|
||||
Reasoning = p.Reasoning,
|
||||
CreateRule = p.CreateRule
|
||||
}).ToList();
|
||||
|
||||
ProposalsJson = JsonSerializer.Serialize(storedProposals);
|
||||
ModelUsed = model ?? SelectedModel;
|
||||
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostApplyAsync(long[] selectedIds, long[] createRules)
|
||||
{
|
||||
if (string.IsNullOrEmpty(ProposalsJson))
|
||||
{
|
||||
ErrorMessage = "No proposals to apply. Please generate new suggestions.";
|
||||
return RedirectToPage("/Recategorize");
|
||||
}
|
||||
|
||||
var storedProposals = JsonSerializer.Deserialize<List<StoredProposal>>(ProposalsJson);
|
||||
if (storedProposals == null || storedProposals.Count == 0)
|
||||
{
|
||||
ErrorMessage = "No proposals to apply.";
|
||||
return RedirectToPage("/Recategorize");
|
||||
}
|
||||
|
||||
var selectedSet = selectedIds?.ToHashSet() ?? new HashSet<long>();
|
||||
var createRulesSet = createRules?.ToHashSet() ?? new HashSet<long>();
|
||||
|
||||
int applied = 0;
|
||||
int rulesCreated = 0;
|
||||
|
||||
foreach (var stored in storedProposals)
|
||||
{
|
||||
if (!selectedSet.Contains(stored.TransactionId))
|
||||
continue;
|
||||
|
||||
var proposal = new AICategoryProposal
|
||||
{
|
||||
TransactionId = stored.TransactionId,
|
||||
Category = stored.Category,
|
||||
CanonicalMerchant = stored.CanonicalMerchant,
|
||||
Pattern = stored.Pattern,
|
||||
Priority = stored.Priority,
|
||||
Confidence = stored.Confidence,
|
||||
Reasoning = stored.Reasoning,
|
||||
CreateRule = stored.CreateRule
|
||||
};
|
||||
|
||||
// Check if user wants to create rule for this one
|
||||
var shouldCreateRule = createRulesSet.Contains(stored.TransactionId);
|
||||
|
||||
var result = await _aiCategorizer.ApplyProposalAsync(stored.TransactionId, proposal, shouldCreateRule);
|
||||
if (result.Success)
|
||||
{
|
||||
applied++;
|
||||
if (result.RuleCreated)
|
||||
rulesCreated++;
|
||||
}
|
||||
}
|
||||
|
||||
SuccessMessage = $"Applied {applied} categorizations. Created {rulesCreated} new mapping rules.";
|
||||
return RedirectToPage("/Recategorize");
|
||||
}
|
||||
|
||||
public IActionResult OnPostCancel()
|
||||
{
|
||||
return RedirectToPage("/Recategorize");
|
||||
}
|
||||
|
||||
private async Task LoadProposalViewModels(List<StoredProposal> storedProposals)
|
||||
{
|
||||
var transactionIds = storedProposals.Select(p => p.TransactionId).ToList();
|
||||
var transactions = await _db.Transactions
|
||||
.Where(t => transactionIds.Contains(t.Id))
|
||||
.ToDictionaryAsync(t => t.Id);
|
||||
|
||||
foreach (var stored in storedProposals)
|
||||
{
|
||||
if (transactions.TryGetValue(stored.TransactionId, out var txn))
|
||||
{
|
||||
Proposals.Add(new ProposalViewModel
|
||||
{
|
||||
TransactionId = stored.TransactionId,
|
||||
TransactionName = txn.Name,
|
||||
TransactionMemo = txn.Memo,
|
||||
TransactionAmount = txn.Amount,
|
||||
TransactionDate = txn.Date,
|
||||
CurrentCategory = txn.Category,
|
||||
ProposedCategory = stored.Category,
|
||||
ProposedMerchant = stored.CanonicalMerchant,
|
||||
ProposedPattern = stored.Pattern,
|
||||
Confidence = stored.Confidence,
|
||||
Reasoning = stored.Reasoning,
|
||||
CreateRule = stored.CreateRule
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Keep proposals in TempData for the apply action
|
||||
ProposalsJson = JsonSerializer.Serialize(storedProposals);
|
||||
}
|
||||
|
||||
public class ProposalViewModel
|
||||
{
|
||||
public long TransactionId { get; set; }
|
||||
public string TransactionName { get; set; } = "";
|
||||
public string? TransactionMemo { get; set; }
|
||||
public decimal TransactionAmount { get; set; }
|
||||
public DateTime TransactionDate { get; set; }
|
||||
public string? CurrentCategory { get; set; }
|
||||
public string ProposedCategory { get; set; } = "";
|
||||
public string? ProposedMerchant { get; set; }
|
||||
public string? ProposedPattern { get; set; }
|
||||
public decimal Confidence { get; set; }
|
||||
public string? Reasoning { get; set; }
|
||||
public bool CreateRule { get; set; }
|
||||
}
|
||||
|
||||
public class StoredProposal
|
||||
{
|
||||
public long TransactionId { get; set; }
|
||||
public string Category { get; set; } = "";
|
||||
public string? CanonicalMerchant { get; set; }
|
||||
public string? Pattern { get; set; }
|
||||
public int Priority { get; set; }
|
||||
public decimal Confidence { get; set; }
|
||||
public string? Reasoning { get; set; }
|
||||
public bool CreateRule { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user