Restore all zeroed views + add bulk gift certificate creation
The HTML entity sweep script had a bug where it wrote empty files for any
view that contained no target Unicode characters, zeroing out 215 view files.
All views restored from the pre-sweep commit (cefdf3e).
Bulk gift certificate feature:
- BulkCreateGiftCertificateDto with Quantity (1-500), Amount, Reason, Expiry, Notes
- GenerateBulkGiftCertificatePdfAsync on IPdfService / PdfService: one Letter page
per cert, reusing the same purple/gold branded ComposeGiftCertificateContent helper
- GiftCertificatesController: BulkCreate GET/POST, BulkResult GET, BulkDownloadPdf POST
- Views: BulkCreate.cshtml (form with live total preview), BulkResult.cshtml (table +
Download All PDF button that POSTs cert IDs to avoid URL length limits)
- gift-certificate-bulk.js: live preview + spinner/disable on submit
- Index.cshtml: Bulk Create button added alongside New Certificate
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
@model PowderCoating.Core.Entities.BankReconciliation
|
||||
@{
|
||||
ViewData["Title"] = "Start Bank Reconciliation";
|
||||
var accounts = ViewBag.AccountSelectList as List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem> ?? new();
|
||||
}
|
||||
|
||||
<div class="d-flex align-items-center mb-3 gap-2">
|
||||
<a asp-action="Index" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back
|
||||
</a>
|
||||
<h4 class="mb-0 fw-semibold ms-2">Start Bank Reconciliation</h4>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm" style="max-width:600px">
|
||||
<div class="card-body">
|
||||
<form asp-action="Create" method="post">
|
||||
@Html.AntiForgeryToken()
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Bank Account <span class="text-danger">*</span></label>
|
||||
<select asp-for="AccountId" asp-items="accounts" class="form-select" required>
|
||||
<option value="">— select account —</option>
|
||||
</select>
|
||||
<div class="form-text">Only Checking, Savings, and Cash accounts are listed.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Statement Date <span class="text-danger">*</span></label>
|
||||
<input asp-for="StatementDate" type="date" class="form-control"
|
||||
value="@Model.StatementDate.ToString("yyyy-MM-dd")" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Statement Ending Balance <span class="text-danger">*</span></label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input asp-for="EndingBalance" type="number" step="0.01" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-text">Enter the closing balance from your bank statement.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Notes</label>
|
||||
<textarea asp-for="Notes" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">Start Reconciliation</button>
|
||||
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
@using PowderCoating.Web.Controllers
|
||||
@{
|
||||
ViewData["Title"] = "Reconcile";
|
||||
var recon = ViewBag.Recon as PowderCoating.Core.Entities.BankReconciliation;
|
||||
var deposits = ViewBag.Deposits as List<ReconciliationItem> ?? new();
|
||||
var payments = ViewBag.Payments as List<ReconciliationItem> ?? new();
|
||||
}
|
||||
|
||||
<div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
|
||||
<a asp-action="Index" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>All Reconciliations
|
||||
</a>
|
||||
<h4 class="mb-0 fw-semibold ms-2">Reconcile: @recon?.Account?.Name</h4>
|
||||
<span class="text-muted small">Statement date: @recon?.StatementDate.ToString("MMM d, yyyy")</span>
|
||||
</div>
|
||||
|
||||
@if (TempData["Error"] != null)
|
||||
{
|
||||
<div class="alert alert-danger alert-permanent alert-dismissible fade show">
|
||||
@TempData["Error"]
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-3 text-center">
|
||||
<div class="card shadow-sm py-3">
|
||||
<div class="fw-bold fs-5">@recon?.BeginningBalance.ToString("C")</div>
|
||||
<div class="text-muted small">Beginning Balance</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 text-center">
|
||||
<div class="card shadow-sm py-3">
|
||||
<div class="fw-bold fs-5">@recon?.EndingBalance.ToString("C")</div>
|
||||
<div class="text-muted small">Statement Ending Balance</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 text-center">
|
||||
<div class="card shadow-sm py-3">
|
||||
<div class="fw-bold fs-5" id="clearedBalance">@recon?.BeginningBalance.ToString("C")</div>
|
||||
<div class="text-muted small">Cleared Balance</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 text-center">
|
||||
<div class="card shadow-sm py-3">
|
||||
<div class="fw-bold fs-5" id="difference">—</div>
|
||||
<div class="text-muted small">Difference</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header fw-semibold">Deposits / Credits</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr><th>Date</th><th>Reference</th><th class="text-end">Amount</th><th class="text-center">Cleared</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (!deposits.Any())
|
||||
{
|
||||
<tr><td colspan="4" class="text-center text-muted py-3">No deposits found.</td></tr>
|
||||
}
|
||||
@foreach (var item in deposits.OrderBy(d => d.Date))
|
||||
{
|
||||
<tr class="recon-row" data-type="@item.EntityType" data-id="@item.EntityId"
|
||||
data-amount="@item.Amount.ToString("F2")" data-direction="deposit">
|
||||
<td class="small">@item.Date.ToString("MMM d")</td>
|
||||
<td class="small text-truncate" style="max-width:120px" title="@item.Description">@item.Reference</td>
|
||||
<td class="text-end">@item.Amount.ToString("C")</td>
|
||||
<td class="text-center">
|
||||
<input type="checkbox" class="form-check-input cleared-checkbox"
|
||||
@(item.IsCleared ? "checked" : "") />
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header fw-semibold">Payments / Debits</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr><th>Date</th><th>Reference</th><th class="text-end">Amount</th><th class="text-center">Cleared</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (!payments.Any())
|
||||
{
|
||||
<tr><td colspan="4" class="text-center text-muted py-3">No payments found.</td></tr>
|
||||
}
|
||||
@foreach (var item in payments)
|
||||
{
|
||||
<tr class="recon-row" data-type="@item.EntityType" data-id="@item.EntityId"
|
||||
data-amount="@item.Amount.ToString("F2")" data-direction="payment">
|
||||
<td class="small">@item.Date.ToString("MMM d")</td>
|
||||
<td class="small text-truncate" style="max-width:120px" title="@item.Description">@item.Reference</td>
|
||||
<td class="text-end">@item.Amount.ToString("C")</td>
|
||||
<td class="text-center">
|
||||
<input type="checkbox" class="form-check-input cleared-checkbox"
|
||||
@(item.IsCleared ? "checked" : "") />
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI Auto-Match panel -->
|
||||
<div class="card shadow-sm mb-3 border-0 bg-light">
|
||||
<div class="card-body d-flex align-items-center gap-3 flex-wrap">
|
||||
<div>
|
||||
<span class="fw-semibold"><i class="bi bi-robot text-primary me-1"></i>AI Auto-Match</span>
|
||||
<span class="text-muted small ms-2">Let Claude suggest which transactions to clear based on amounts and dates.</span>
|
||||
</div>
|
||||
<button id="aiMatchBtn" class="btn btn-outline-primary btn-sm ms-auto" type="button">
|
||||
<i class="bi bi-magic me-1"></i>Suggest Matches
|
||||
</button>
|
||||
</div>
|
||||
<div id="aiMatchResult" class="d-none px-3 pb-3">
|
||||
<div id="aiMatchInsights" class="mb-2 text-muted small"></div>
|
||||
<div id="aiMatchActions" class="d-flex gap-2 flex-wrap">
|
||||
<button id="aiMatchAccept" class="btn btn-sm btn-success d-none">
|
||||
<i class="bi bi-check-all me-1"></i>Apply All Suggestions
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form asp-action="Complete" method="post" id="completeForm">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="id" value="@recon?.Id" />
|
||||
<input type="hidden" name="difference" id="differenceHidden" value="9999" />
|
||||
<button type="submit" class="btn btn-success" id="completeBtn" disabled>
|
||||
<i class="bi bi-check-circle me-1"></i>Complete Reconciliation
|
||||
</button>
|
||||
<span class="ms-2 text-muted small">Complete is enabled only when difference is $0.00</span>
|
||||
</form>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
(function() {
|
||||
const reconId = @recon?.Id;
|
||||
const beginning = @recon?.BeginningBalance.ToString("F2");
|
||||
const ending = @recon?.EndingBalance.ToString("F2");
|
||||
let token = document.querySelector('input[name="__RequestVerificationToken"]').value;
|
||||
|
||||
function recalculate() {
|
||||
let clearedDeposits = 0, clearedPayments = 0;
|
||||
document.querySelectorAll('.recon-row').forEach(row => {
|
||||
const cb = row.querySelector('.cleared-checkbox');
|
||||
const amt = parseFloat(row.dataset.amount);
|
||||
if (!cb.checked) return;
|
||||
if (row.dataset.direction === 'deposit') clearedDeposits += amt;
|
||||
else clearedPayments += amt;
|
||||
});
|
||||
|
||||
const cleared = beginning + clearedDeposits - clearedPayments;
|
||||
const difference = ending - cleared;
|
||||
|
||||
document.getElementById('clearedBalance').textContent =
|
||||
cleared.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||
|
||||
const diffEl = document.getElementById('difference');
|
||||
diffEl.textContent = difference.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||
diffEl.className = Math.abs(difference) < 0.005 ? 'fw-bold fs-5 text-success' : 'fw-bold fs-5 text-danger';
|
||||
|
||||
document.getElementById('differenceHidden').value = difference.toFixed(2);
|
||||
document.getElementById('completeBtn').disabled = Math.abs(difference) >= 0.005;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.cleared-checkbox').forEach(cb => {
|
||||
cb.addEventListener('change', async function() {
|
||||
const row = this.closest('.recon-row');
|
||||
const type = row.dataset.type;
|
||||
const id = row.dataset.id;
|
||||
const cleared = this.checked;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/BankReconciliations/ToggleCleared', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'RequestVerificationToken': token
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
reconId, entityType: type, entityId: id, isCleared: cleared
|
||||
})
|
||||
});
|
||||
if (!resp.ok) this.checked = !cleared; // revert on error
|
||||
} catch {
|
||||
this.checked = !cleared; // revert on network error
|
||||
}
|
||||
recalculate();
|
||||
});
|
||||
});
|
||||
|
||||
recalculate();
|
||||
|
||||
// ── AI Auto-Match ──────────────────────────────────────────────────────────
|
||||
let aiSuggestions = [];
|
||||
|
||||
document.getElementById('aiMatchBtn')?.addEventListener('click', async function() {
|
||||
const btn = this;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Analyzing…';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/BankReconciliations/AiSuggestMatches', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'RequestVerificationToken': token
|
||||
},
|
||||
body: new URLSearchParams({ reconId })
|
||||
});
|
||||
const data = await resp.json();
|
||||
|
||||
const resultEl = document.getElementById('aiMatchResult');
|
||||
const insightsEl = document.getElementById('aiMatchInsights');
|
||||
resultEl.classList.remove('d-none');
|
||||
|
||||
if (!data.success) {
|
||||
insightsEl.innerHTML = `<span class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>${data.errorMessage || 'AI unavailable.'}</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
aiSuggestions = data.suggestedCleared || [];
|
||||
|
||||
// Highlight suggested rows
|
||||
aiSuggestions.forEach(s => {
|
||||
const row = document.querySelector(`.recon-row[data-type="${s.entityType}"][data-id="${s.entityId}"]`);
|
||||
if (row) {
|
||||
row.classList.add('table-info');
|
||||
const td = row.querySelector('td:last-child');
|
||||
if (td) {
|
||||
const pct = Math.round(s.confidence * 100);
|
||||
td.insertAdjacentHTML('afterend', `<td class="small text-info" style="white-space:nowrap">${pct}% — ${s.reason}</td>`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Insights
|
||||
const insights = data.insights || [];
|
||||
insightsEl.innerHTML = insights.map(i => `<i class="bi bi-lightbulb me-1 text-warning"></i>${i}`).join('<br>');
|
||||
|
||||
if (aiSuggestions.length > 0) {
|
||||
document.getElementById('aiMatchAccept').classList.remove('d-none');
|
||||
} else {
|
||||
insightsEl.innerHTML += '<br><span class="text-muted">No high-confidence suggestions found — review items manually.</span>';
|
||||
}
|
||||
} catch (err) {
|
||||
document.getElementById('aiMatchInsights').innerHTML = '<span class="text-danger">Error contacting AI service.</span>';
|
||||
document.getElementById('aiMatchResult').classList.remove('d-none');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-magic me-1"></i>Suggest Matches';
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('aiMatchAccept')?.addEventListener('click', async function() {
|
||||
for (const s of aiSuggestions) {
|
||||
const row = document.querySelector(`.recon-row[data-type="${s.entityType}"][data-id="${s.entityId}"]`);
|
||||
if (!row) continue;
|
||||
const cb = row.querySelector('.cleared-checkbox');
|
||||
if (!cb || cb.checked) continue;
|
||||
cb.checked = true;
|
||||
// Persist via the existing toggle endpoint
|
||||
try {
|
||||
await fetch('/BankReconciliations/ToggleCleared', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': token },
|
||||
body: new URLSearchParams({ reconId, entityType: s.entityType, entityId: s.entityId, isCleared: true })
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
recalculate();
|
||||
this.textContent = 'Applied';
|
||||
this.disabled = true;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
@model PowderCoating.Core.Entities.BankReconciliation
|
||||
@using PowderCoating.Web.Controllers
|
||||
@{
|
||||
ViewData["Title"] = $"Reconciliation Report – {Model.Account?.Name}";
|
||||
var clearedDeposits = ViewBag.ClearedDeposits as IEnumerable<PowderCoating.Core.Entities.Payment> ?? Enumerable.Empty<PowderCoating.Core.Entities.Payment>();
|
||||
var clearedPayments = ViewBag.ClearedPayments as List<ReconciliationItem> ?? new();
|
||||
}
|
||||
|
||||
<div class="d-flex align-items-center mb-3 gap-2 no-print">
|
||||
<a asp-action="Index" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back
|
||||
</a>
|
||||
<h4 class="mb-0 fw-semibold ms-2">Reconciliation Report</h4>
|
||||
<button class="btn btn-sm btn-outline-secondary ms-auto" onclick="window.print()">
|
||||
<i class="bi bi-printer me-1"></i>Print
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h5 class="fw-semibold">@Model.Account?.Name</h5>
|
||||
<p class="text-muted mb-0">Statement Date: @Model.StatementDate.ToString("MMMM d, yyyy")</p>
|
||||
@if (Model.CompletedAt.HasValue)
|
||||
{
|
||||
<p class="text-muted small">Completed by @Model.CompletedBy on @Model.CompletedAt.Value.ToLocalTime().ToString("MMM d, yyyy")</p>
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-6 text-md-end">
|
||||
<table class="table table-sm table-borderless mb-0 ms-auto" style="width:auto">
|
||||
<tr>
|
||||
<td class="text-muted">Beginning Balance:</td>
|
||||
<td class="fw-semibold text-end">@Model.BeginningBalance.ToString("C")</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">+ Cleared Deposits:</td>
|
||||
<td class="fw-semibold text-end text-success">@clearedDeposits.Sum(p => p.Amount).ToString("C")</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">– Cleared Payments:</td>
|
||||
<td class="fw-semibold text-end text-danger">@clearedPayments.Sum(p => p.Amount).ToString("C")</td>
|
||||
</tr>
|
||||
<tr class="border-top">
|
||||
<td class="fw-semibold">Statement Ending Balance:</td>
|
||||
<td class="fw-bold text-end">@Model.EndingBalance.ToString("C")</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header fw-semibold">Cleared Deposits (@clearedDeposits.Count())</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light"><tr><th>Date</th><th>Reference</th><th class="text-end">Amount</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var p in clearedDeposits.OrderBy(p => p.PaymentDate))
|
||||
{
|
||||
<tr>
|
||||
<td class="small">@p.PaymentDate.ToString("MMM d")</td>
|
||||
<td class="small">@p.Reference</td>
|
||||
<td class="text-end">@p.Amount.ToString("C")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot class="table-light fw-semibold">
|
||||
<tr><td colspan="2" class="text-end">Total</td><td class="text-end">@clearedDeposits.Sum(p=>p.Amount).ToString("C")</td></tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header fw-semibold">Cleared Payments (@clearedPayments.Count)</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light"><tr><th>Date</th><th>Reference</th><th class="text-end">Amount</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var p in clearedPayments)
|
||||
{
|
||||
<tr>
|
||||
<td class="small">@p.Date.ToString("MMM d")</td>
|
||||
<td class="small">@p.Reference</td>
|
||||
<td class="text-end">@p.Amount.ToString("C")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot class="table-light fw-semibold">
|
||||
<tr><td colspan="2" class="text-end">Total</td><td class="text-end">@clearedPayments.Sum(p=>p.Amount).ToString("C")</td></tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Notes))
|
||||
{
|
||||
<div class="card shadow-sm mt-3">
|
||||
<div class="card-header fw-semibold">Notes</div>
|
||||
<div class="card-body">@Model.Notes</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user