Compare commits
2 Commits
cefdf3e35c
...
4ec55e7290
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ec55e7290 | |||
| 3eda91f170 |
@@ -87,3 +87,27 @@ public class RedeemGiftCertificateDto
|
|||||||
[Range(0.01, 9999.99)]
|
[Range(0.01, 9999.99)]
|
||||||
public decimal Amount { get; set; }
|
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,
|
byte[]? companyLogo,
|
||||||
string? companyLogoContentType,
|
string? companyLogoContentType,
|
||||||
CompanyInfoDto companyInfo);
|
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>
|
/// <summary>
|
||||||
/// Composes the gift certificate body with a decorative double-border frame (outer purple 3pt,
|
/// 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
|
/// 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;
|
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)
|
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company)
|
||||||
{
|
{
|
||||||
if (company == null) return (null, null);
|
if (company == null) return (null, null);
|
||||||
|
|||||||
@@ -232,4 +232,3 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</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 — 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 — 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> — 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")
|
||||||
|
: "—")
|
||||||
|
</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>
|
</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>
|
<i class="bi bi-gift me-2" style="font-size:1.4rem;"></i>
|
||||||
<div>
|
<div>
|
||||||
<strong>@statusLabel</strong>
|
<strong>@statusLabel</strong>
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
}
|
}
|
||||||
@if (Model.ExpiryDate.HasValue)
|
@if (Model.ExpiryDate.HasValue)
|
||||||
{
|
{
|
||||||
<span class="ms-2 small">· 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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,11 +7,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<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 — @((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">
|
<a asp-action="Create" class="btn btn-primary">
|
||||||
<i class="bi bi-plus-circle me-2"></i>New Certificate
|
<i class="bi bi-plus-circle me-2"></i>New Certificate
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="card border-0 shadow-sm mb-4">
|
<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...';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}());
|
||||||
Reference in New Issue
Block a user