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,337 @@
@model PowderCoating.Application.DTOs.AI.CatalogPriceCheckReportDto?
@{
ViewData["Title"] = "AI Catalog Price Check";
ViewData["PageIcon"] = "bi-robot";
ViewData["PageHelpTitle"] = "AI Catalog Price Check";
ViewData["PageHelpContent"] = "The AI Price Check reviews every item in your catalog against your actual operating costs and flags items that may be priced below cost, have thin margins, or appear unusually high. Results are estimates based on industry knowledge and your shop's rates — always apply your own judgment before changing prices.";
var sortedResults = Model?.Results
.OrderBy(r => r.Verdict switch
{
"below-cost" => 0,
"low" => 1,
"high" => 2,
_ => 3
})
.ThenBy(r => r.Name)
.ToList() ?? new List<PowderCoating.Application.DTOs.AI.CatalogItemPriceVerdict>();
}
@section Styles {
<style>
.verdict-badge { font-size: 0.8rem; font-weight: 600; padding: 0.3em 0.7em; border-radius: 20px; }
.verdict-below-cost { background: #fee2e2; color: #991b1b; }
.verdict-low { background: #fef3c7; color: #92400e; }
.verdict-high { background: #e0e7ff; color: #3730a3; }
.verdict-ok { background: #d1fae5; color: #065f46; }
.confidence-low { opacity: 0.6; }
.price-card { border-left: 4px solid #e5e7eb; }
.price-card.below-cost { border-left-color: #ef4444; }
.price-card.low { border-left-color: #f59e0b; }
.price-card.high { border-left-color: #6366f1; }
.price-card.ok { border-left-color: #10b981; }
.cost-table td { font-size: 0.85rem; }
.summary-stat { text-align: center; }
.summary-stat .num { font-size: 2rem; font-weight: 700; line-height: 1; }
.summary-stat .lbl { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.7; }
.run-btn-wrap { min-height: 3rem; }
/* Progress overlay */
#price-check-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.55);
z-index: 1050;
align-items: center;
justify-content: center;
}
#price-check-overlay.active { display: flex; }
.progress-card {
background: #fff;
border-radius: 1rem;
padding: 2.5rem 2rem;
width: 100%;
max-width: 440px;
text-align: center;
box-shadow: 0 20px 60px rgba(0,0,0,0.25);
}
.progress-card .icon { font-size: 3rem; color: #4f46e5; margin-bottom: 1rem; }
.progress-card h5 { font-weight: 700; margin-bottom: 0.25rem; }
.progress-card .status-msg { font-size: 0.9rem; color: #64748b; min-height: 1.4em; margin-bottom: 1.25rem; }
.progress-bar-track {
height: 8px;
background: #e2e8f0;
border-radius: 99px;
overflow: hidden;
margin-bottom: 0.75rem;
}
.progress-bar-fill {
height: 100%;
background: linear-gradient(90deg, #4f46e5, #7c3aed);
border-radius: 99px;
width: 0%;
transition: width 0.6s ease;
}
.progress-card .pct-label { font-size: 0.8rem; color: #94a3b8; }
</style>
}
<!-- Progress overlay (shown while AI is running) -->
<div id="price-check-overlay">
<div class="progress-card">
<div class="icon"><i class="bi bi-robot"></i></div>
<h5>Analyzing your catalog</h5>
<p class="status-msg" id="overlay-status">Preparing items…</p>
<div class="progress-bar-track">
<div class="progress-bar-fill" id="overlay-bar"></div>
</div>
<div class="pct-label"><span id="overlay-pct">0</span>% complete</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-4">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i> Back to Catalog
</a>
@if (!(bool)(ViewBag.AiPriceCheckEnabled ?? true))
{
<div class="text-end">
<button class="btn btn-primary" disabled>
<i class="bi bi-robot me-2"></i>Analyze Catalog with AI
</button>
<div class="small text-muted mt-1">Available on the Enterprise plan</div>
</div>
}
else if (ViewBag.NextRunAvailable != null)
{
<div class="text-end">
<button class="btn btn-primary" disabled>
<i class="bi bi-robot me-2"></i>Analyze Catalog with AI
</button>
<div class="small text-muted mt-1">Next run available: @ViewBag.NextRunAvailable</div>
</div>
}
else
{
<form asp-action="RunAiPriceCheck" method="post" id="runForm" class="run-btn-wrap">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-primary" id="runBtn"
data-item-count="@(ViewBag.ActiveItemCount ?? 0)">
<i class="bi bi-robot me-2"></i>Analyze Catalog with AI
</button>
</form>
}
</div>
@if (TempData["Success"] != null)
{
<div class="alert alert-success alert-permanent mb-4">
<i class="bi bi-check-circle me-2"></i>@TempData["Success"]
</div>
}
@if (TempData["Warning"] != null)
{
<div class="alert alert-warning alert-permanent mb-4">
<i class="bi bi-exclamation-triangle me-2"></i>@TempData["Warning"]
</div>
}
@if (TempData["Error"] != null)
{
<div class="alert alert-danger alert-permanent mb-4">
<i class="bi bi-x-circle me-2"></i>@TempData["Error"]
</div>
}
<!-- What this does -->
<div class="card mb-4 border-0 bg-light">
<div class="card-body">
<div class="d-flex gap-3">
<div class="flex-shrink-0 text-primary" style="font-size:1.75rem;"><i class="bi bi-info-circle"></i></div>
<div>
<h6 class="fw-semibold mb-1">What this analysis does</h6>
<p class="small text-muted mb-2">
Our AI system reviews every active, priced item in your catalog against your shop's actual operating costs —
labor, oven time, sandblasting, coating booth, and powder material. For each item it estimates a
realistic surface area and processing time, calculates a cost floor, then compares that to your
current price and returns one of four verdicts:
</p>
<div class="d-flex flex-wrap gap-2 mb-2">
<span class="verdict-badge verdict-below-cost">Below Cost</span><span class="small text-muted align-self-center">— you're losing money on this item</span>
</div>
<div class="d-flex flex-wrap gap-2 mb-2">
<span class="verdict-badge verdict-low">Thin Margin</span><span class="small text-muted align-self-center">— above cost floor but below your target margin</span>
</div>
<div class="d-flex flex-wrap gap-2 mb-2">
<span class="verdict-badge verdict-high">High</span><span class="small text-muted align-self-center">— significantly above typical market rates</span>
</div>
<div class="d-flex flex-wrap gap-2 mb-3">
<span class="verdict-badge verdict-ok">OK</span><span class="small text-muted align-self-center">— price is within a reasonable range</span>
</div>
<p class="small text-muted mb-0">
<i class="bi bi-exclamation-triangle me-1 text-warning"></i>
Results are estimates based on industry knowledge and your shop's rates. Always apply your own
judgment before changing prices. Make sure your
<a asp-controller="CompanySettings" asp-action="Index">operating costs</a> are up to date for the most accurate results.
Analysis can be run once per quarter.
</p>
</div>
</div>
</div>
</div>
@if (Model == null)
{
<!-- Empty state -->
<div class="card text-center py-5">
<div class="card-body">
<i class="bi bi-robot text-muted" style="font-size: 4rem;"></i>
<h4 class="mt-3">No analysis has been run yet</h4>
<p class="text-muted mb-4">
Click <strong>Analyze Catalog with AI</strong> above to get started.
</p>
</div>
</div>
}
else
{
<!-- Summary cards -->
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="card h-100">
<div class="card-body summary-stat">
<div class="num text-danger">@Model.BelowCostCount</div>
<div class="lbl mt-1">Below Cost</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card h-100">
<div class="card-body summary-stat">
<div class="num text-warning">@Model.LowMarginCount</div>
<div class="lbl mt-1">Thin Margin</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card h-100">
<div class="card-body summary-stat">
<div class="num text-primary">@Model.HighPriceCount</div>
<div class="lbl mt-1">Possibly High</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card h-100">
<div class="card-body summary-stat">
<div class="num text-success">@Model.OkCount</div>
<div class="lbl mt-1">Looks Good</div>
</div>
</div>
</div>
</div>
<!-- Meta / costs used -->
<div class="card mb-4">
<div class="card-body d-flex flex-wrap align-items-center gap-3">
<i class="bi bi-clock-history text-muted"></i>
<span class="text-muted small">
Run @Model.RunAt.ToLocalTime().ToString("MMM d, yyyy h:mm tt") &bull;
@Model.ItemsChecked items checked
</span>
<span class="badge bg-light text-secondary small ms-auto">
Costs used: @Model.OperatingCostsSummary
</span>
</div>
</div>
<!-- Results list -->
<div class="row g-3">
@foreach (var item in sortedResults!)
{
var cardClass = item.Verdict switch
{
"below-cost" => "below-cost",
"low" => "low",
"high" => "high",
_ => "ok"
};
var verdictClass = item.Verdict switch
{
"below-cost" => "verdict-below-cost",
"low" => "verdict-low",
"high" => "verdict-high",
_ => "verdict-ok"
};
var verdictLabel = item.Verdict switch
{
"below-cost" => "Below Cost",
"low" => "Thin Margin",
"high" => "High",
_ => "OK"
};
<div class="col-12 col-lg-6">
<div class="card price-card @cardClass @(item.Confidence == "low" ? "confidence-low" : "")">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<strong>@item.Name</strong>
@if (item.Confidence == "low")
{
<span class="badge bg-light text-secondary ms-2" title="Item name was too vague for a confident estimate">
<i class="bi bi-question-circle me-1"></i>Low confidence
</span>
}
</div>
<span class="verdict-badge @verdictClass">@verdictLabel</span>
</div>
<div class="row g-2 mb-2">
<div class="col-4 text-center">
<div class="small text-muted">Current</div>
<div class="fw-semibold">@item.CurrentPrice.ToString("C")</div>
</div>
<div class="col-4 text-center">
<div class="small text-muted">Cost Floor</div>
<div class="fw-semibold @(item.CostFloor > item.CurrentPrice ? "text-danger" : "")">
@item.CostFloor.ToString("C")
</div>
</div>
<div class="col-4 text-center">
<div class="small text-muted">Suggested</div>
<div class="fw-semibold text-primary">
@item.SuggestedPriceMin.ToString("C") @item.SuggestedPriceMax.ToString("C")
</div>
</div>
</div>
<div class="small text-muted mb-1">
<i class="bi bi-rulers me-1"></i>
Est. @item.EstimatedSqFtMin@item.EstimatedSqFtMax sqft &bull;
@item.EstimatedMinutesMin@item.EstimatedMinutesMax min
</div>
<p class="small mb-1">@item.Reasoning</p>
<details class="small">
<summary class="text-muted" style="cursor:pointer;">Assumptions</summary>
<p class="mt-1 mb-0 text-muted">@item.Assumptions</p>
</details>
<div class="mt-2 text-end">
<a asp-action="Edit" asp-route-id="@item.CatalogItemId"
class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil me-1"></i>Edit Price
</a>
</div>
</div>
</div>
</div>
}
</div>
}
@section Scripts {
<script src="~/js/catalog-price-check.js"></script>
}
@@ -0,0 +1,376 @@
@model PowderCoating.Application.DTOs.Catalog.CreateCatalogItemDto
@{
ViewData["Title"] = "Add Catalog Item";
var allowAccounting = Context.Items["AllowAccounting"] as bool? ?? false;
}
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h4 class="mb-0">
<i class="bi bi-plus-circle me-2"></i>Add Catalog Item
</h4>
</div>
<div class="card-body">
<form asp-action="Create" method="post" enctype="multipart/form-data">
<partial name="_ValidationSummary" />
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-4" role="alert">
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
<div>
<strong>Catalog item prices are fixed.</strong> The price you enter here is exactly what gets charged when this item is added to a quote or job — no markup, no prep service charges, and no complexity adjustments are added on top. Make sure your price already includes labor, materials, and margin.
</div>
</div>
<!-- Basic Information Section -->
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0">Basic Information</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Basic Information"
data-bs-content="Give the item a descriptive name (e.g., '18 inch Aluminum Wheel') so staff can find it quickly in the picker. The category groups it in the catalog tree. The description appears on quotes and invoices so customers know exactly what they're being charged for.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="mb-3">
<div class="d-flex align-items-center gap-1">
<label asp-for="Name" class="form-label required mb-0"></label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Item Name"
data-bs-content="Use a specific, recognizable name. The name appears on quotes, invoices, and in the picker dropdown. Good names include material and size where relevant — e.g., 'Steel Bracket (6in)', '18in Alloy Wheel'.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<input asp-for="Name" class="form-control" placeholder="e.g., 18 inch Aluminum Wheel" />
<span asp-validation-for="Name" class="text-danger small"></span>
</div>
<div class="mb-3">
<label asp-for="CategoryId" class="form-label required"></label>
<div class="input-group">
<select asp-for="CategoryId" id="categorySelect" class="form-select" asp-items="ViewBag.Categories">
<option value="">-- Select Category --</option>
</select>
<button type="button" class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#addCategoryModal">
<i class="bi bi-plus-circle me-1"></i>New
</button>
</div>
<span asp-validation-for="CategoryId" class="text-danger small"></span>
<small class="form-text text-muted">Select an existing category or click "New" to create one.</small>
</div>
<div class="mb-3">
<label asp-for="Description" class="form-label"></label>
<textarea asp-for="Description" class="form-control" rows="3" placeholder="Detailed description of the item..."></textarea>
<span asp-validation-for="Description" class="text-danger small"></span>
</div>
<!-- Pricing Section -->
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3 mt-4">
<h5 class="mb-0">Pricing</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Pricing"
data-bs-content="The Default Price is the exact amount charged when this item is added to a quote — no markup, prep services, or complexity adjustments are applied on top. Set the all-in price you want to bill. Approximate Area is optional — if set, it helps estimate powder needed for reporting purposes.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<div class="d-flex align-items-center gap-1">
<label asp-for="DefaultPrice" class="form-label required mb-0"></label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Default Price"
data-bs-content="This is the final price charged to the customer — no markup, prep services, or complexity charges are added on top. Enter the all-in amount you want to bill for this item. Staff can still override it on individual quotes if needed.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="input-group">
<span class="input-group-text">$</span>
<input asp-for="DefaultPrice" class="form-control" placeholder="0.00" />
</div>
<span asp-validation-for="DefaultPrice" class="text-danger small"></span>
</div>
<div class="col-md-6 mb-3">
<div class="d-flex align-items-center gap-1">
<label for="ApproximateArea" class="form-label mb-0">Approximate @ViewBag.AreaUnit</label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Approximate Surface Area"
data-bs-content="Optional. If you know the typical surface area for this item type (e.g., 4.5 sqft for an 18in wheel), enter it here. The quoting wizard will use it to auto-calculate powder needed and oven cost. Staff can always override it per quote item.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<input asp-for="ApproximateArea" class="form-control" placeholder="0.00" step="0.01" />
<span asp-validation-for="ApproximateArea" class="text-danger small"></span>
<small class="form-text text-muted">Optional: Surface area for quoting purposes</small>
</div>
</div>
@if (allowAccounting)
{
<!-- Financial Accounts Section -->
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3 mt-4">
<h5 class="mb-0">Financial Accounts</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Financial Accounts"
data-bs-content="Links this item to your chart of accounts for accounting exports. Revenue Account is credited when invoiced; COGS Account is debited when materials are consumed. Leave both blank to use the company default accounts — most shops only need to set these for items with special accounting treatment.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<p class="text-muted small mb-3">Map this item to chart-of-account entries for proper revenue and cost tracking. Leave blank to use defaults.</p>
<div class="row">
<div class="col-md-6 mb-3">
<label asp-for="RevenueAccountId" class="form-label"></label>
<select asp-for="RevenueAccountId" class="form-select" asp-items="ViewBag.RevenueAccounts">
<option value="">(Default revenue account)</option>
</select>
<small class="form-text text-muted">Account credited when this item is invoiced.</small>
</div>
<div class="col-md-6 mb-3">
<label asp-for="CogsAccountId" class="form-label"></label>
<select asp-for="CogsAccountId" class="form-select" asp-items="ViewBag.CogsAccounts"
data-quick-add-url="/Accounts/Create?preSubType=40" data-quick-add-title="Add COGS Account">
<option value="">(Default COGS account)</option>
<option value="__new__">+ Add New Account…</option>
</select>
<small class="form-text text-muted">Account debited when materials are consumed.</small>
</div>
</div>
}
<!-- Merchandise Flag -->
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3 mt-4">
<h5 class="mb-0">Sale Options</h5>
</div>
<div class="form-check mb-3">
<input asp-for="IsMerchandise" class="form-check-input" type="checkbox" />
<label asp-for="IsMerchandise" class="form-check-label fw-semibold">
Available for direct sale (merchandise)
</label>
<div class="form-text">When checked, this item appears in the merchandise picker on invoices and can be sold without a job (e.g. branded apparel, retail cleaning products).</div>
</div>
<!-- Item Image -->
<h5 class="border-bottom pb-2 mb-3 mt-4">Item Image <span class="text-muted small fw-normal">(optional)</span></h5>
<div class="mb-3">
<label for="image" class="form-label fw-semibold">Upload Image</label>
<input type="file" class="form-control" id="image" name="image" accept="image/jpeg,image/png,image/gif,image/webp" onchange="previewCatalogImage(this)" />
<div class="form-text">Accepted formats: jpg, jpeg, png, gif, webp — max 10 MB. A 200×200 thumbnail is generated automatically.</div>
<div id="imagePreview" class="mt-2 d-none">
<img id="imagePreviewImg" src="" alt="Preview" style="max-width:200px;max-height:200px;object-fit:contain;border:1px solid #dee2e6;border-radius:6px;" />
</div>
</div>
<!-- Actions -->
<div class="d-flex justify-content-between mt-4">
<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">
<i class="bi bi-check-circle me-1"></i>Create Item
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Add Category Modal -->
<div class="modal fade" id="addCategoryModal" tabindex="-1" aria-labelledby="addCategoryModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="addCategoryModalLabel">
<i class="bi bi-folder-plus me-2"></i>Add New Category
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="newCategoryName" class="form-label">Category Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="newCategoryName" placeholder="e.g., Wheels, Engine Parts" required />
</div>
<div class="mb-3">
<label for="newCategoryParent" class="form-label">Parent Category</label>
<select class="form-select" id="newCategoryParent">
<option value="">(None - Root Category)</option>
</select>
<small class="form-text text-muted">Select a parent category to create a subcategory, or leave as "(None)" for a root category.</small>
</div>
<div class="mb-3">
<label for="newCategoryDescription" class="form-label">Description (Optional)</label>
<textarea class="form-control" id="newCategoryDescription" rows="2" placeholder="Brief description..."></textarea>
</div>
<div id="categoryModalError" class="alert alert-danger d-none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="saveCategoryBtn">
<i class="bi bi-check-circle me-1"></i>Create Category
</button>
</div>
</div>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<script>
document.addEventListener('DOMContentLoaded', function () {
const modal = new bootstrap.Modal(document.getElementById('addCategoryModal'));
const saveBtn = document.getElementById('saveCategoryBtn');
const categorySelect = document.getElementById('categorySelect');
const parentCategorySelect = document.getElementById('newCategoryParent');
const errorDiv = document.getElementById('categoryModalError');
// Load categories into parent dropdown when modal opens
document.getElementById('addCategoryModal').addEventListener('show.bs.modal', async function () {
try {
// Get existing categories from the main dropdown
parentCategorySelect.innerHTML = '<option value="">(None - Root Category)</option>';
// Copy options from main category dropdown (excluding the placeholder)
const mainOptions = categorySelect.options;
for (let i = 0; i < mainOptions.length; i++) {
if (mainOptions[i].value) { // Skip empty placeholder option
const option = new Option(mainOptions[i].text, mainOptions[i].value);
parentCategorySelect.add(option);
}
}
} catch (error) {
console.error('Error loading parent categories:', error);
}
});
saveBtn.addEventListener('click', async function () {
const name = document.getElementById('newCategoryName').value.trim();
const description = document.getElementById('newCategoryDescription').value.trim();
const parentCategoryId = parentCategorySelect.value || null;
if (!name) {
showError('Category name is required.');
return;
}
// Disable button during save
saveBtn.disabled = true;
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Creating...';
try {
const response = await fetch('@Url.Action("QuickCreate", "CatalogCategories")', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]').value
},
body: JSON.stringify({
name: name,
description: description || null,
parentCategoryId: parentCategoryId
})
});
const result = await response.json();
if (result.success) {
// Reload category dropdowns to show new category in correct hierarchical position
await reloadCategoryDropdowns(result.id);
// Show success message
showToast('success', result.message);
// Reset and close modal
document.getElementById('newCategoryName').value = '';
document.getElementById('newCategoryDescription').value = '';
parentCategorySelect.value = '';
errorDiv.classList.add('d-none');
modal.hide();
} else {
showError(result.message);
}
} catch (error) {
showError('An error occurred while creating the category.');
console.error('Error:', error);
} finally {
// Re-enable button
saveBtn.disabled = false;
saveBtn.innerHTML = '<i class="bi bi-check-circle me-1"></i>Create Category';
}
});
// Reset modal when closed
document.getElementById('addCategoryModal').addEventListener('hidden.bs.modal', function () {
document.getElementById('newCategoryName').value = '';
document.getElementById('newCategoryDescription').value = '';
parentCategorySelect.value = '';
errorDiv.classList.add('d-none');
});
function showError(message) {
errorDiv.textContent = message;
errorDiv.classList.remove('d-none');
}
function showToast(type, message) {
// Create a simple toast notification
const toast = document.createElement('div');
toast.className = `alert alert-${type} alert-dismissible fade show position-fixed top-0 start-50 translate-middle-x mt-3`;
toast.style.zIndex = '9999';
toast.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
async function reloadCategoryDropdowns(selectedCategoryId) {
try {
const response = await fetch('@Url.Action("GetCategoriesForDropdown", "CatalogCategories")');
const data = await response.json();
if (data.success) {
// Rebuild main category dropdown
categorySelect.innerHTML = '<option value="">-- Select Category --</option>';
data.categories.forEach(cat => {
const option = new Option(cat.displayText, cat.id, cat.id === selectedCategoryId, cat.id === selectedCategoryId);
categorySelect.add(option);
});
// Rebuild parent category dropdown in modal
parentCategorySelect.innerHTML = '<option value="">(None - Root Category)</option>';
data.categories.forEach(cat => {
const option = new Option(cat.displayText, cat.id);
parentCategorySelect.add(option);
});
}
} catch (error) {
console.error('Error reloading category dropdowns:', error);
}
}
});
function previewCatalogImage(input) {
const preview = document.getElementById('imagePreview');
const img = document.getElementById('imagePreviewImg');
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = e => { img.src = e.target.result; preview.classList.remove('d-none'); };
reader.readAsDataURL(input.files[0]);
} else {
preview.classList.add('d-none');
}
}
</script>
}
@@ -0,0 +1,398 @@
@model PowderCoating.Application.DTOs.Catalog.UpdateCatalogItemDto
@{
ViewData["Title"] = "Edit Catalog Item";
var allowAccounting = Context.Items["AllowAccounting"] as bool? ?? false;
}
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h4 class="mb-0">
<i class="bi bi-pencil me-2"></i>Edit Catalog Item
</h4>
</div>
<div class="card-body">
<form asp-action="Edit" method="post" enctype="multipart/form-data">
<input type="hidden" asp-for="Id" />
<partial name="_ValidationSummary" />
<!-- Basic Information Section -->
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0">Basic Information</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Basic Information"
data-bs-content="Give the item a descriptive name (e.g., '18 inch Aluminum Wheel') so staff can find it quickly in the picker. The category groups it in the catalog tree. The description appears on quotes and invoices so customers know exactly what they're being charged for.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="mb-3">
<div class="d-flex align-items-center gap-1">
<label asp-for="Name" class="form-label required mb-0"></label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Item Name"
data-bs-content="Use a specific, recognizable name. The name appears on quotes, invoices, and in the picker dropdown. Good names include material and size where relevant — e.g., 'Steel Bracket (6in)', '18in Alloy Wheel'.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<input asp-for="Name" class="form-control" />
<span asp-validation-for="Name" class="text-danger small"></span>
</div>
<div class="mb-3">
<label asp-for="CategoryId" class="form-label required"></label>
<div class="input-group">
<select asp-for="CategoryId" id="categorySelect" class="form-select" asp-items="ViewBag.Categories">
<option value="">-- Select Category --</option>
</select>
<button type="button" class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#addCategoryModal">
<i class="bi bi-plus-circle me-1"></i>New
</button>
</div>
<span asp-validation-for="CategoryId" class="text-danger small"></span>
<small class="form-text text-muted">Select an existing category or click "New" to create one.</small>
</div>
<div class="mb-3">
<label asp-for="Description" class="form-label"></label>
<textarea asp-for="Description" class="form-control" rows="3"></textarea>
<span asp-validation-for="Description" class="text-danger small"></span>
</div>
<!-- Pricing & Status Section -->
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3 mt-4">
<h5 class="mb-0">Pricing &amp; Status</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Pricing &amp; Status"
data-bs-content="The Default Price is the exact amount charged when this item is added to a quote — no markup, prep services, or complexity adjustments are applied on top. Staff can override it on individual quotes. Set Status to Inactive to hide the item from the picker without deleting it.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="row mb-3">
<div class="col-md-6">
<div class="d-flex align-items-center gap-1">
<label asp-for="DefaultPrice" class="form-label required mb-0"></label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Default Price"
data-bs-content="This is the final price charged to the customer — no markup, prep services, or complexity charges are added on top. Enter the all-in amount you want to bill for this item. Staff can still override it on individual quotes if needed.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="input-group">
<span class="input-group-text">$</span>
<input asp-for="DefaultPrice" class="form-control" />
</div>
<span asp-validation-for="DefaultPrice" class="text-danger small"></span>
</div>
<div class="col-md-6">
<div class="d-flex align-items-center gap-1">
<label for="ApproximateArea" class="form-label mb-0">Approximate @ViewBag.AreaUnit</label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Approximate Surface Area"
data-bs-content="Optional. If you know the typical surface area for this item type (e.g., 4.5 sqft for an 18in wheel), enter it here. The quoting wizard will use it to auto-calculate powder needed and oven cost. Staff can always override it per quote item.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<input asp-for="ApproximateArea" class="form-control" placeholder="0.00" step="0.01" />
<span asp-validation-for="ApproximateArea" class="text-danger small"></span>
<small class="form-text text-muted">Optional: Surface area for quoting purposes</small>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">Status</label>
<div class="form-check form-switch mt-2">
<input asp-for="IsActive" class="form-check-input" role="switch" />
<label asp-for="IsActive" class="form-check-label"></label>
</div>
</div>
</div>
@if (allowAccounting)
{
<!-- Financial Accounts Section -->
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3 mt-4">
<h5 class="mb-0">Financial Accounts</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Financial Accounts"
data-bs-content="Links this item to your chart of accounts for accounting exports. Revenue Account is credited when invoiced; COGS Account is debited when materials are consumed. Leave both blank to use the company default accounts — most shops only need to set these for items with special accounting treatment.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<p class="text-muted small mb-3">Map this item to chart-of-account entries for proper revenue and cost tracking. Leave blank to use defaults.</p>
<div class="row">
<div class="col-md-6 mb-3">
<label asp-for="RevenueAccountId" class="form-label"></label>
<select asp-for="RevenueAccountId" class="form-select" asp-items="ViewBag.RevenueAccounts">
<option value="">(Default revenue account)</option>
</select>
<small class="form-text text-muted">Account credited when this item is invoiced.</small>
</div>
<div class="col-md-6 mb-3">
<label asp-for="CogsAccountId" class="form-label"></label>
<select asp-for="CogsAccountId" class="form-select" asp-items="ViewBag.CogsAccounts"
data-quick-add-url="/Accounts/Create?preSubType=40" data-quick-add-title="Add COGS Account">
<option value="">(Default COGS account)</option>
<option value="__new__">+ Add New Account…</option>
</select>
<small class="form-text text-muted">Account debited when materials are consumed.</small>
</div>
</div>
}
<!-- Merchandise Flag -->
<div class="form-check mb-3 mt-3">
<input asp-for="IsMerchandise" class="form-check-input" type="checkbox" />
<label asp-for="IsMerchandise" class="form-check-label fw-semibold">
Available for direct sale (merchandise)
</label>
<div class="form-text">When checked, this item appears in the merchandise picker on invoices and can be sold without a job.</div>
</div>
<!-- Item Image -->
<h5 class="border-bottom pb-2 mb-3 mt-4">Item Image <span class="text-muted small fw-normal">(optional)</span></h5>
@if (ViewBag.HasImage == true)
{
<div class="mb-3 d-flex align-items-start gap-3">
<div>
<img id="imagePreviewImg"
src="@Url.Action("Image", "CatalogItems", new { id = Model.Id, thumbnail = true })"
alt="Current image"
style="width:100px;height:100px;object-fit:cover;border-radius:6px;border:1px solid #dee2e6;" />
</div>
<div>
<p class="mb-1 fw-semibold">Current Image</p>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" name="removeImage" id="removeImage" value="true" />
<label class="form-check-label text-danger" for="removeImage">Remove current image</label>
</div>
<label for="image" class="form-label text-muted small">Replace with a new image:</label>
<input type="file" class="form-control form-control-sm" id="image" name="image" accept="image/jpeg,image/png,image/gif,image/webp" onchange="previewCatalogImage(this)" />
</div>
</div>
}
else
{
<div class="mb-3">
<label for="image" class="form-label fw-semibold">Upload Image</label>
<input type="file" class="form-control" id="image" name="image" accept="image/jpeg,image/png,image/gif,image/webp" onchange="previewCatalogImage(this)" />
<div class="form-text">Accepted formats: jpg, jpeg, png, gif, webp — max 10 MB. A 200×200 thumbnail is generated automatically.</div>
<div id="imagePreview" class="mt-2 d-none">
<img id="imagePreviewImg" src="" alt="Preview" style="max-width:200px;max-height:200px;object-fit:contain;border:1px solid #dee2e6;border-radius:6px;" />
</div>
</div>
}
<!-- Actions -->
<div class="d-flex justify-content-between mt-4">
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Cancel
</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle me-1"></i>Save Changes
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Add Category Modal -->
<div class="modal fade" id="addCategoryModal" tabindex="-1" aria-labelledby="addCategoryModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="addCategoryModalLabel">
<i class="bi bi-folder-plus me-2"></i>Add New Category
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="newCategoryName" class="form-label">Category Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="newCategoryName" placeholder="e.g., Wheels, Engine Parts" required />
</div>
<div class="mb-3">
<label for="newCategoryParent" class="form-label">Parent Category</label>
<select class="form-select" id="newCategoryParent">
<option value="">(None - Root Category)</option>
</select>
<small class="form-text text-muted">Select a parent category to create a subcategory, or leave as "(None)" for a root category.</small>
</div>
<div class="mb-3">
<label for="newCategoryDescription" class="form-label">Description (Optional)</label>
<textarea class="form-control" id="newCategoryDescription" rows="2" placeholder="Brief description..."></textarea>
</div>
<div id="categoryModalError" class="alert alert-danger d-none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="saveCategoryBtn">
<i class="bi bi-check-circle me-1"></i>Create Category
</button>
</div>
</div>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<script>
document.addEventListener('DOMContentLoaded', function () {
const modal = new bootstrap.Modal(document.getElementById('addCategoryModal'));
const saveBtn = document.getElementById('saveCategoryBtn');
const categorySelect = document.getElementById('categorySelect');
const parentCategorySelect = document.getElementById('newCategoryParent');
const errorDiv = document.getElementById('categoryModalError');
// Load categories into parent dropdown when modal opens
document.getElementById('addCategoryModal').addEventListener('show.bs.modal', async function () {
try {
// Get existing categories from the main dropdown
parentCategorySelect.innerHTML = '<option value="">(None - Root Category)</option>';
// Copy options from main category dropdown (excluding the placeholder)
const mainOptions = categorySelect.options;
for (let i = 0; i < mainOptions.length; i++) {
if (mainOptions[i].value) { // Skip empty placeholder option
const option = new Option(mainOptions[i].text, mainOptions[i].value);
parentCategorySelect.add(option);
}
}
} catch (error) {
console.error('Error loading parent categories:', error);
}
});
saveBtn.addEventListener('click', async function () {
const name = document.getElementById('newCategoryName').value.trim();
const description = document.getElementById('newCategoryDescription').value.trim();
const parentCategoryId = parentCategorySelect.value || null;
if (!name) {
showError('Category name is required.');
return;
}
saveBtn.disabled = true;
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Creating...';
try {
const response = await fetch('@Url.Action("QuickCreate", "CatalogCategories")', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]').value
},
body: JSON.stringify({
name: name,
description: description || null,
parentCategoryId: parentCategoryId
})
});
const result = await response.json();
if (result.success) {
// Reload category dropdowns to show new category in correct hierarchical position
await reloadCategoryDropdowns(result.id);
// Show success message
showToast('success', result.message);
// Reset and close modal
document.getElementById('newCategoryName').value = '';
document.getElementById('newCategoryDescription').value = '';
parentCategorySelect.value = '';
errorDiv.classList.add('d-none');
modal.hide();
} else {
showError(result.message);
}
} catch (error) {
showError('An error occurred while creating the category.');
console.error('Error:', error);
} finally {
saveBtn.disabled = false;
saveBtn.innerHTML = '<i class="bi bi-check-circle me-1"></i>Create Category';
}
});
document.getElementById('addCategoryModal').addEventListener('hidden.bs.modal', function () {
document.getElementById('newCategoryName').value = '';
document.getElementById('newCategoryDescription').value = '';
parentCategorySelect.value = '';
errorDiv.classList.add('d-none');
});
function showError(message) {
errorDiv.textContent = message;
errorDiv.classList.remove('d-none');
}
function showToast(type, message) {
const toast = document.createElement('div');
toast.className = `alert alert-${type} alert-dismissible fade show position-fixed top-0 start-50 translate-middle-x mt-3`;
toast.style.zIndex = '9999';
toast.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
async function reloadCategoryDropdowns(selectedCategoryId) {
try {
const response = await fetch('@Url.Action("GetCategoriesForDropdown", "CatalogCategories")');
const data = await response.json();
if (data.success) {
// Rebuild main category dropdown
categorySelect.innerHTML = '<option value="">-- Select Category --</option>';
data.categories.forEach(cat => {
const option = new Option(cat.displayText, cat.id, cat.id === selectedCategoryId, cat.id === selectedCategoryId);
categorySelect.add(option);
});
// Rebuild parent category dropdown in modal
parentCategorySelect.innerHTML = '<option value="">(None - Root Category)</option>';
data.categories.forEach(cat => {
const option = new Option(cat.displayText, cat.id);
parentCategorySelect.add(option);
});
}
} catch (error) {
console.error('Error reloading category dropdowns:', error);
}
}
});
function previewCatalogImage(input) {
const preview = document.getElementById('imagePreview');
const img = document.getElementById('imagePreviewImg');
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = e => {
img.src = e.target.result;
if (preview) preview.classList.remove('d-none');
};
reader.readAsDataURL(input.files[0]);
} else {
if (preview) preview.classList.add('d-none');
}
}
</script>
}
@@ -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>