Compare commits

...

2 Commits

Author SHA1 Message Date
spouliot 4ec55e7290 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>
2026-05-14 20:09:22 -04:00
spouliot 3eda91f170 Replace literal Unicode special chars with HTML entities across all 233 views
Sweeps em dashes, en dashes, multiplication signs, ellipses, and curly quotes
to their HTML entity equivalents (&mdash; &ndash; &times; &hellip; &lsquo; &rsquo;)
in all .cshtml files, skipping <script> blocks. Prevents encoding corruption
from AI tools and Windows encoding mismatches that caused recurring symbol bugs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:16:17 -04:00
10 changed files with 496 additions and 7 deletions
@@ -87,3 +87,27 @@ public class RedeemGiftCertificateDto
[Range(0.01, 9999.99)]
public decimal Amount { get; set; }
}
public class BulkCreateGiftCertificateDto
{
[Required]
[Range(1, 500, ErrorMessage = "Quantity must be between 1 and 500.")]
[Display(Name = "Number of Certificates")]
public int Quantity { get; set; } = 25;
[Required]
[Range(1.00, 9999.99, ErrorMessage = "Amount must be between $1.00 and $9,999.99.")]
[Display(Name = "Face Value (each)")]
public decimal Amount { get; set; }
[Required]
[Display(Name = "Issued Reason")]
public GiftCertificateIssuedReason IssuedReason { get; set; } = GiftCertificateIssuedReason.Promotional;
[Display(Name = "Expiry Date (optional)")]
public DateTime? ExpiryDate { get; set; }
[StringLength(1000)]
[Display(Name = "Event / Notes (applied to all certificates)")]
public string? Notes { get; set; }
}
@@ -51,4 +51,10 @@ public interface IPdfService
byte[]? companyLogo,
string? companyLogoContentType,
CompanyInfoDto companyInfo);
Task<byte[]> GenerateBulkGiftCertificatePdfAsync(
IList<GiftCertificateDto> certs,
byte[]? companyLogo,
string? companyLogoContentType,
CompanyInfoDto companyInfo);
}
@@ -1858,6 +1858,50 @@ public class PdfService : IPdfService
});
}
/// <summary>
/// Generates a multi-page PDF containing one gift certificate per page, all using the same
/// branded layout as the single-certificate download. Used for bulk print runs (car shows,
/// promotions) so staff can hand-cut and distribute a full batch from one print job.
/// </summary>
public async Task<byte[]> GenerateBulkGiftCertificatePdfAsync(
IList<GiftCertificateDto> certs,
byte[]? companyLogo,
string? companyLogoContentType,
CompanyInfoDto companyInfo)
{
QuestPDF.Settings.License = LicenseType.Community;
const string accent = "#7c3aed";
const string gold = "#b45309";
return await Task.Run(() =>
{
var doc = Document.Create(container =>
{
foreach (var cert in certs)
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.Margin(0.75f, Unit.Inch);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
page.Content().Element(c => ComposeGiftCertificateContent(c, cert, companyInfo, companyLogo, accent, gold));
page.Footer().AlignCenter().Text(text =>
{
text.Span(companyInfo.CompanyName).FontSize(8).FontColor(Colors.Grey.Darken1);
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
text.Span($" · {FormatPhoneNumber(companyInfo.Phone)}").FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
}
});
return doc.GeneratePdf();
});
}
/// <summary>
/// Composes the gift certificate body with a decorative double-border frame (outer purple 3pt,
/// inner gold 1pt) that gives the document a premium printed-certificate appearance. Inside the
@@ -440,6 +440,184 @@ public class GiftCertificatesController : Controller
return acct?.Id;
}
/// <summary>
/// Shows the bulk certificate creation form. Defaults to Promotional reason and 25 certificates
/// since the primary use case is car shows and events where a batch of same-value certificates
/// is distributed to attendees.
/// </summary>
public IActionResult BulkCreate()
{
return View(new BulkCreateGiftCertificateDto());
}
/// <summary>
/// Creates N gift certificates in a single batch, records GL entries for each, then redirects
/// to a confirmation page where the user can download the full batch as a single print-ready PDF.
/// Certificate codes are generated sequentially so the batch occupies a contiguous range (e.g.
/// GC-2506-0012 through GC-2506-0036), making it easy to audit which codes belong to each event.
/// GL treatment mirrors single-certificate issuance: Sold certs debit Checking, all others debit
/// Sales Discounts (4950) and credit GC Liability (2500).
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> BulkCreate(BulkCreateGiftCertificateDto dto)
{
if (!ModelState.IsValid)
return View(dto);
try
{
var currentUser = await _userManager.GetUserAsync(User);
var companyId = currentUser?.CompanyId ?? 0;
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(companyId);
int? checkingAcctId = null;
int? discountAcctId = null;
if (dto.IssuedReason == GiftCertificateIssuedReason.Sold)
{
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && (a.AccountSubType == AccountSubTypeEnum.Checking
|| a.AccountSubType == AccountSubTypeEnum.Cash));
checkingAcctId = acct?.Id;
}
else
{
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && a.AccountNumber == "4950");
discountAcctId = acct?.Id;
}
var createdIds = new List<int>();
var now = DateTime.UtcNow;
for (int i = 0; i < dto.Quantity; i++)
{
var code = await GenerateCertificateCodeAsync(companyId);
var cert = new GiftCertificate
{
CertificateCode = code,
OriginalAmount = dto.Amount,
RedeemedAmount = 0,
IssuedReason = dto.IssuedReason,
Status = GiftCertificateStatus.Active,
IssueDate = now,
ExpiryDate = dto.ExpiryDate,
Notes = dto.Notes,
IssuedById = currentUser?.Id,
CompanyId = companyId,
CreatedAt = now,
CreatedBy = currentUser?.Email
};
await _unitOfWork.GiftCertificates.AddAsync(cert);
await _unitOfWork.CompleteAsync();
createdIds.Add(cert.Id);
await _accountBalanceService.CreditAsync(gcLiabilityAcctId, cert.OriginalAmount);
if (dto.IssuedReason == GiftCertificateIssuedReason.Sold)
await _accountBalanceService.DebitAsync(checkingAcctId, cert.OriginalAmount);
else
await _accountBalanceService.DebitAsync(discountAcctId, cert.OriginalAmount);
}
TempData["BulkCertIds"] = System.Text.Json.JsonSerializer.Serialize(createdIds);
TempData["BulkCertCount"] = dto.Quantity;
TempData["BulkCertAmount"] = dto.Amount.ToString("F2");
return RedirectToAction(nameof(BulkResult));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating bulk gift certificates");
this.ToastError("An error occurred creating the certificates.");
return View(dto);
}
}
/// <summary>
/// Displays the bulk creation confirmation page with the list of generated certificate codes
/// and a button to download all as a single print-ready PDF.
/// </summary>
public async Task<IActionResult> BulkResult()
{
if (TempData["BulkCertIds"] is not string json)
return RedirectToAction(nameof(Index));
var ids = System.Text.Json.JsonSerializer.Deserialize<List<int>>(json) ?? new();
var certs = await _unitOfWork.GiftCertificates.FindAsync(gc => ids.Contains(gc.Id), false);
ViewBag.CertCount = TempData["BulkCertCount"];
ViewBag.CertAmount = TempData["BulkCertAmount"];
ViewBag.CertIds = ids;
return View(certs.OrderBy(c => c.CertificateCode).ToList());
}
/// <summary>
/// Generates and streams a single PDF containing one page per certificate in the batch.
/// The certificate IDs are posted as a form array so there is no URL-length limit on batch size.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> BulkDownloadPdf(int[] certIds)
{
if (certIds == null || certIds.Length == 0)
return BadRequest();
var currentUser = await _userManager.GetUserAsync(User);
var companyId = currentUser?.CompanyId ?? 0;
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
var companyInfo = new Application.DTOs.Company.CompanyInfoDto
{
CompanyName = company?.CompanyName ?? string.Empty,
Phone = company?.Phone,
Address = company?.Address,
City = company?.City,
State = company?.State,
ZipCode = company?.ZipCode,
PrimaryContactEmail = company?.PrimaryContactEmail
};
var certs = await _unitOfWork.GiftCertificates.FindAsync(
gc => certIds.Contains(gc.Id), false,
gc => gc.RecipientCustomer);
var dtos = certs.OrderBy(c => c.CertificateCode).Select(cert => new GiftCertificateDto
{
Id = cert.Id,
CertificateCode = cert.CertificateCode,
OriginalAmount = cert.OriginalAmount,
RedeemedAmount = cert.RedeemedAmount,
RemainingBalance = cert.RemainingBalance,
RecipientName = cert.RecipientCustomer != null
? (cert.RecipientCustomer.CompanyName ?? $"{cert.RecipientCustomer.ContactFirstName} {cert.RecipientCustomer.ContactLastName}".Trim())
: cert.RecipientName,
RecipientEmail = cert.RecipientEmail,
IssuedReason = cert.IssuedReason,
Status = cert.Status,
IssueDate = cert.IssueDate,
ExpiryDate = cert.ExpiryDate,
Notes = cert.Notes
}).ToList();
try
{
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
var pdfBytes = await _pdfService.GenerateBulkGiftCertificatePdfAsync(dtos, logoData, logoContentType, companyInfo);
var firstName = dtos.FirstOrDefault()?.CertificateCode ?? "batch";
var lastName = dtos.LastOrDefault()?.CertificateCode ?? string.Empty;
var fileName = dtos.Count == 1
? $"GiftCertificate-{firstName}.pdf"
: $"GiftCertificates-{firstName}-to-{lastName}.pdf";
return File(pdfBytes, "application/pdf", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating bulk gift certificate PDF");
TempData["Error"] = "Could not generate PDF.";
return RedirectToAction(nameof(Index));
}
}
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company)
{
if (company == null) return (null, null);
@@ -232,4 +232,3 @@
});
</script>
}
@@ -0,0 +1,107 @@
@model PowderCoating.Application.DTOs.GiftCertificate.BulkCreateGiftCertificateDto
@using PowderCoating.Core.Enums
@{
ViewData["Title"] = "Bulk Create Gift Certificates";
ViewData["PageIcon"] = "bi-gift";
}
<div class="row justify-content-center">
<div class="col-lg-7">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-bottom py-3">
<h5 class="mb-0">
<i class="bi bi-collection me-2 text-primary"></i>Bulk Gift Certificate Generator
</h5>
<p class="text-muted small mb-0 mt-1">
Create a batch of certificates for car shows, events, or promotions. All certificates will have the same
face value and be generated with sequential codes ready to print.
</p>
</div>
<div class="card-body p-4">
<form asp-action="BulkCreate" method="post">
<div asp-validation-summary="ModelOnly" class="alert alert-danger" role="alert"></div>
<div class="row g-3">
<div class="col-md-5">
<label asp-for="Quantity" class="form-label fw-semibold">
<i class="bi bi-123 me-1 text-muted"></i>@Html.DisplayNameFor(m => m.Quantity)
</label>
<input asp-for="Quantity" type="number" class="form-control form-control-lg"
min="1" max="500" placeholder="25" />
<span asp-validation-for="Quantity" class="text-danger small"></span>
<div class="form-text">Max 500 per batch.</div>
</div>
<div class="col-md-7">
<label asp-for="Amount" class="form-label fw-semibold">
<i class="bi bi-currency-dollar me-1 text-muted"></i>@Html.DisplayNameFor(m => m.Amount)
</label>
<div class="input-group input-group-lg">
<span class="input-group-text">$</span>
<input asp-for="Amount" type="number" class="form-control"
min="1" max="9999.99" step="0.01" placeholder="50.00" />
</div>
<span asp-validation-for="Amount" class="text-danger small"></span>
</div>
<div class="col-12">
<label asp-for="IssuedReason" class="form-label fw-semibold">
<i class="bi bi-tag me-1 text-muted"></i>@Html.DisplayNameFor(m => m.IssuedReason)
</label>
<select asp-for="IssuedReason" class="form-select">
@foreach (var reason in Enum.GetValues<GiftCertificateIssuedReason>())
{
<option value="@reason">@reason</option>
}
</select>
<span asp-validation-for="IssuedReason" class="text-danger small"></span>
</div>
<div class="col-12">
<label asp-for="ExpiryDate" class="form-label fw-semibold">
<i class="bi bi-calendar-x me-1 text-muted"></i>@Html.DisplayNameFor(m => m.ExpiryDate)
</label>
<input asp-for="ExpiryDate" type="date" class="form-control" />
<span asp-validation-for="ExpiryDate" class="text-danger small"></span>
<div class="form-text">Leave blank for no expiration.</div>
</div>
<div class="col-12">
<label asp-for="Notes" class="form-label fw-semibold">
<i class="bi bi-chat-left-text me-1 text-muted"></i>@Html.DisplayNameFor(m => m.Notes)
</label>
<textarea asp-for="Notes" class="form-control" rows="2"
placeholder="e.g. Awarded at the 2026 Summer Car Show &mdash; thanks for attending!"></textarea>
<span asp-validation-for="Notes" class="text-danger small"></span>
<div class="form-text">Printed on every certificate in the batch.</div>
</div>
</div>
<!-- Preview summary -->
<div id="batchPreview" class="alert alert-primary mt-4 mb-0" style="display:none">
<i class="bi bi-info-circle me-2"></i>
You are about to create <strong id="prevQty"></strong> certificates worth
<strong id="prevAmt"></strong> each &mdash; total face value
<strong id="prevTotal"></strong>.
</div>
<div class="d-flex justify-content-between align-items-center mt-4 pt-3 border-top">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Cancel
</a>
<button type="submit" class="btn btn-primary btn-lg" id="submitBtn">
<i class="bi bi-plus-circle me-2"></i>Create Certificates
</button>
</div>
</form>
</div>
</div>
</div>
</div>
@section Scripts {
<script src="~/js/gift-certificate-bulk.js" asp-append-version="true"></script>
}
@@ -0,0 +1,89 @@
@model List<PowderCoating.Core.Entities.GiftCertificate>
@using PowderCoating.Core.Enums
@{
ViewData["Title"] = "Batch Created";
ViewData["PageIcon"] = "bi-gift";
var count = ViewBag.CertCount as int? ?? Model.Count;
var amount = ViewBag.CertAmount as string ?? "0.00";
var ids = ViewBag.CertIds as List<int> ?? Model.Select(c => c.Id).ToList();
}
<div class="alert alert-success alert-permanent mb-4">
<i class="bi bi-check-circle-fill me-2"></i>
<strong>@count gift certificates created</strong> &mdash; each worth $@amount.
Download the PDF below to print the full batch.
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-bottom d-flex justify-content-between align-items-center py-3">
<h5 class="mb-0">
<i class="bi bi-collection me-2 text-primary"></i>Batch Certificates (@count)
</h5>
<form asp-action="BulkDownloadPdf" method="post">
@Html.AntiForgeryToken()
@foreach (var id in ids)
{
<input type="hidden" name="certIds" value="@id" />
}
<button type="submit" class="btn btn-primary">
<i class="bi bi-file-pdf me-2"></i>Download All as PDF
</button>
</form>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Certificate Code</th>
<th>Face Value</th>
<th>Issued</th>
<th>Expiry</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var cert in Model)
{
<tr>
<td class="fw-mono fw-semibold">@cert.CertificateCode</td>
<td>@cert.OriginalAmount.ToString("C")</td>
<td>@cert.IssueDate.ToLocalTime().ToString("MMM d, yyyy")</td>
<td>
@(cert.ExpiryDate.HasValue
? cert.ExpiryDate.Value.ToLocalTime().ToString("MMM d, yyyy")
: "&mdash;")
</td>
<td><span class="badge bg-success">Active</span></td>
<td class="text-end">
<a asp-action="Details" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-eye"></i>
</a>
<a asp-action="DownloadPdf" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-secondary" title="Download single PDF">
<i class="bi bi-file-pdf"></i>
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<div class="card-footer bg-white border-top d-flex justify-content-between align-items-center py-3">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to Gift Certificates
</a>
<form asp-action="BulkDownloadPdf" method="post">
@Html.AntiForgeryToken()
@foreach (var id in ids)
{
<input type="hidden" name="certIds" value="@id" />
}
<button type="submit" class="btn btn-primary">
<i class="bi bi-printer me-2"></i>Print Batch PDF (@count pages)
</button>
</form>
</div>
</div>
@@ -28,7 +28,7 @@
</div>
</div>
<div class="alert alert-@statusClass alert-permanent d-flex align-items-center mb-4">
<div class="alert alert-@statusClass d-flex align-items-center mb-4">
<i class="bi bi-gift me-2" style="font-size:1.4rem;"></i>
<div>
<strong>@statusLabel</strong>
@@ -38,7 +38,7 @@
}
@if (Model.ExpiryDate.HasValue)
{
<span class="ms-2 small">&middot; Expires @Model.ExpiryDate.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MMMM d, yyyy")</span>
<span class="ms-2 small">· Expires @Model.ExpiryDate.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MMMM d, yyyy")</span>
}
</div>
</div>
@@ -7,11 +7,16 @@
}
<div class="d-flex justify-content-between align-items-center mb-4">
<p class="text-muted mb-0">@ViewBag.TotalActive active certificates @((ViewBag.TotalValue as decimal? ?? 0m).ToString("C")) outstanding value</p>
<p class="text-muted mb-0">@ViewBag.TotalActive active certificates &mdash; @((ViewBag.TotalValue as decimal? ?? 0m).ToString("C")) outstanding value</p>
<div class="d-flex gap-2">
<a asp-action="BulkCreate" class="btn btn-outline-primary">
<i class="bi bi-collection me-2"></i>Bulk Create
</a>
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>New Certificate
</a>
</div>
</div>
<!-- Filters -->
<div class="card border-0 shadow-sm mb-4">
@@ -0,0 +1,37 @@
(function () {
var qtyInput = document.getElementById('Quantity');
var amtInput = document.getElementById('Amount');
var preview = document.getElementById('batchPreview');
var prevQty = document.getElementById('prevQty');
var prevAmt = document.getElementById('prevAmt');
var prevTotal = document.getElementById('prevTotal');
function updatePreview() {
var qty = parseInt(qtyInput.value, 10);
var amt = parseFloat(amtInput.value);
if (qty > 0 && amt > 0) {
prevQty.textContent = qty;
prevAmt.textContent = '$' + amt.toFixed(2);
prevTotal.textContent = '$' + (qty * amt).toFixed(2);
preview.style.display = '';
} else {
preview.style.display = 'none';
}
}
if (qtyInput && amtInput) {
qtyInput.addEventListener('input', updatePreview);
amtInput.addEventListener('input', updatePreview);
updatePreview();
}
// Disable submit button after first click to prevent double-submit during long creation
var form = document.querySelector('form');
var submitBtn = document.getElementById('submitBtn');
if (form && submitBtn) {
form.addEventListener('submit', function () {
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Creating...';
});
}
}());