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:
2026-05-14 20:09:22 -04:00
parent 3eda91f170
commit 4ec55e7290
240 changed files with 73116 additions and 0 deletions
@@ -0,0 +1,220 @@
@model List<PowderCoating.Web.Controllers.CategoryWithItems>
@{
ViewData["Title"] = "Product Catalog";
ViewData["PageIcon"] = "bi-book";
ViewData["PageHelpTitle"] = "Product Catalog";
ViewData["PageHelpContent"] = "The Product Catalog is a library of standard items (wheels, brackets, panels, etc.) that your shop regularly quotes and invoices. Each item has a fixed price — when a catalog item is added to a quote or job, that price is used exactly as entered. No markup, no prep services, and no complexity charges are added on top. Organize items into categories to keep the catalog easy to browse.";
var totalItemsCount = ViewBag.TotalItemsCount ?? 0;
var activeItemsCount = ViewBag.ActiveItemsCount ?? 0;
var averagePrice = ViewBag.AveragePrice ?? 0m;
var categoryCount = ViewBag.CategoryCount ?? 0;
var currentCategoryId = ViewBag.CurrentCategoryId;
var searchTerm = ViewBag.SearchTerm ?? "";
var hasFilters = ViewBag.HasFilters ?? false;
var filteredItemsCount = ViewBag.FilteredItemsCount ?? 0;
}
@section Styles {
<link rel="stylesheet" href="~/css/catalog.css" />
}
@section Scripts {
<script src="~/js/catalog.js"></script>
}
<div class="d-flex justify-content-end align-items-center gap-2 mb-4">
<a asp-action="AiPriceCheck" class="btn btn-outline-primary text-nowrap">
<i class="bi bi-robot me-2"></i>
<span class="d-none d-sm-inline">AI Price Check</span>
<span class="d-inline d-sm-none">AI</span>
</a>
<a asp-action="ExportCatalogPdf" class="btn btn-primary text-nowrap">
<i class="bi bi-file-pdf me-2"></i>
<span class="d-none d-sm-inline">Export Product Catalog to PDF</span>
<span class="d-inline d-sm-none">PDF</span>
</a>
</div>
<!-- Stats Cards -->
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="card catalog-stats-card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="catalog-text-muted mb-1" style="font-size: 0.875rem;">Total Items</p>
<h3 class="mb-0 fw-bold">@totalItemsCount</h3>
</div>
<div class="catalog-stats-icon blue">
<i class="bi bi-box-seam text-primary" style="font-size: 1.5rem;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card catalog-stats-card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="catalog-text-muted mb-1" style="font-size: 0.875rem;">Active Items</p>
<h3 class="mb-0 fw-bold">@activeItemsCount</h3>
</div>
<div class="catalog-stats-icon green">
<i class="bi bi-check-circle text-success" style="font-size: 1.5rem;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card catalog-stats-card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="catalog-text-muted mb-1" style="font-size: 0.875rem;">Average Price</p>
<h3 class="mb-0 fw-bold">@averagePrice.ToString("C")</h3>
</div>
<div class="catalog-stats-icon yellow">
<i class="bi bi-cash-stack text-warning" style="font-size: 1.5rem;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card catalog-stats-card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="catalog-text-muted mb-1" style="font-size: 0.875rem;">Categories</p>
<h3 class="mb-0 fw-bold">@categoryCount</h3>
</div>
<div class="catalog-stats-icon pink">
<i class="bi bi-folder text-danger" style="font-size: 1.5rem;"></i>
</div>
</div>
</div>
</div>
</div>
</div>
@if (hasFilters)
{
<div class="alert alert-info alert-permanent d-flex justify-content-between align-items-center">
<div>
<i class="bi bi-funnel me-2"></i>
Showing <strong>@filteredItemsCount</strong> item(s)
@if (!string.IsNullOrEmpty(searchTerm))
{
<span> matching "<strong>@searchTerm</strong>"</span>
}
@if (currentCategoryId != null)
{
var categoryName = (ViewBag.Categories as List<SelectListItem>)?.FirstOrDefault(c => c.Value == currentCategoryId.ToString())?.Text;
<span> in category "<strong>@categoryName</strong>"</span>
}
</div>
<a href="@Url.Action("Index")" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-x me-1"></i>Clear Filters
</a>
</div>
}
<!-- Catalog Items Card -->
<div class="card border-0 shadow-sm">
<div class="card-header catalog-card-header py-3">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-start align-items-lg-center gap-3">
<div class="d-flex flex-column flex-sm-row gap-2 w-100 w-lg-auto">
<form asp-action="Index" method="get" class="d-flex flex-column flex-sm-row gap-2 flex-grow-1 flex-lg-grow-0">
<div class="input-group catalog-search-group">
<span class="input-group-text catalog-search-icon">
<i class="bi bi-search catalog-text-muted"></i>
</span>
<input type="text" name="searchTerm" class="form-control catalog-search-input"
placeholder="Search items..."
value="@searchTerm"
aria-label="Search catalog items">
</div>
<select name="categoryId" class="form-select catalog-category-select">
<option value="">All Categories</option>
@if (ViewBag.Categories != null)
{
foreach (var category in ViewBag.Categories as List<SelectListItem>)
{
<option value="@category.Value" selected="@(category.Value == currentCategoryId?.ToString())">
@category.Text
</option>
}
}
</select>
<button type="submit" class="btn btn-primary">
<i class="bi bi-search"></i>
</button>
@if (hasFilters)
{
<a href="@Url.Action("Index")" class="btn btn-outline-secondary" title="Clear filters">
<i class="bi bi-x-lg"></i>
</a>
}
</form>
<div class="d-flex gap-2">
<a asp-controller="CatalogCategories" asp-action="Index" class="btn btn-outline-secondary text-nowrap">
<i class="bi bi-folder me-2"></i>
<span class="d-none d-sm-inline">Manage Categories</span>
<span class="d-inline d-sm-none">Categories</span>
</a>
<a asp-action="Create" class="btn btn-primary text-nowrap">
<i class="bi bi-plus-circle me-2"></i>
<span class="d-none d-sm-inline">Add Item</span>
<span class="d-inline d-sm-none">Add</span>
</a>
</div>
</div>
</div>
</div>
<div class="card-body p-0">
@if (!Model.Any())
{
<div class="text-center py-5">
<i class="bi bi-inbox catalog-empty-icon" style="font-size: 4rem;"></i>
<h5 class="mt-3 catalog-text-secondary">No catalog items found</h5>
@if (hasFilters)
{
<p class="catalog-text-muted mb-4">Try adjusting your filters</p>
<a href="@Url.Action("Index")" class="btn btn-outline-secondary">
<i class="bi bi-x-circle me-2"></i>Clear Filters
</a>
}
else
{
<p class="catalog-text-muted mb-4">Get started by creating your first catalog item</p>
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>Create Your First Item
</a>
}
</div>
}
else
{
<!-- Nested Category View -->
<div class="catalog-tree p-3">
@foreach (var categoryWithItems in Model)
{
<partial name="_CategoryNode" model="categoryWithItems" />
}
</div>
<script>
(function () {
var PREFIX = 'pcl_catalog_acc_';
document.querySelectorAll('.catalog-tree .collapse').forEach(function (el) {
var stored = localStorage.getItem(PREFIX + el.id);
if (stored === null) return;
if (stored === '1') { el.classList.add('show'); }
else { el.classList.remove('show'); }
});
}());
</script>
}
</div>
</div>