Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,353 @@
@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">
<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>
<!-- 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-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);
}
}
});
</script>
}
@@ -0,0 +1,84 @@
@model PowderCoating.Application.DTOs.Catalog.CatalogItemDto
@{
ViewData["Title"] = "Delete Catalog Item";
}
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<h4 class="mb-0">
<i class="bi bi-exclamation-triangle me-2"></i>Delete Catalog Item
</h4>
</div>
<div class="card-body">
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<strong>Warning:</strong> You are about to delete this catalog item. This action cannot be undone.
</div>
<h5 class="mb-3">Are you sure you want to delete this item?</h5>
<!-- Item Details -->
<div class="card mb-4">
<div class="card-body">
<div class="row mb-2">
<div class="col-md-4"><strong>Name:</strong></div>
<div class="col-md-8">@Model.Name</div>
</div>
@if (!string.IsNullOrWhiteSpace(Model.SKU))
{
<div class="row mb-2">
<div class="col-md-4"><strong>SKU:</strong></div>
<div class="col-md-8">@Model.SKU</div>
</div>
}
<div class="row mb-2">
<div class="col-md-4"><strong>Category:</strong></div>
<div class="col-md-8">
<span class="badge bg-secondary">@Model.FullCategoryPath</span>
</div>
</div>
<div class="row mb-2">
<div class="col-md-4"><strong>Default Price:</strong></div>
<div class="col-md-8">@Model.DefaultPrice.ToString("C")</div>
</div>
@if (!string.IsNullOrWhiteSpace(Model.Description))
{
<div class="row mb-2">
<div class="col-md-4"><strong>Description:</strong></div>
<div class="col-md-8">@Model.Description</div>
</div>
}
<div class="row mb-2">
<div class="col-md-4"><strong>Status:</strong></div>
<div class="col-md-8">
@if (Model.IsActive)
{
<span class="badge bg-success">Active</span>
}
else
{
<span class="badge bg-secondary">Inactive</span>
}
</div>
</div>
</div>
</div>
<form asp-action="Delete" method="post">
<div class="d-flex justify-content-between">
<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-danger">
<i class="bi bi-trash me-1"></i>Delete Item
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,192 @@
@model PowderCoating.Application.DTOs.Catalog.CatalogItemDto
@{
ViewData["Title"] = Model.Name;
}
<div class="container">
<div class="row">
<!-- Left Column: Item Details -->
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h4 class="mb-0">
<i class="bi bi-box-seam me-2"></i>@Model.Name
</h4>
</div>
<div class="card-body">
<!-- Basic Information -->
<h5 class="border-bottom pb-2 mb-3">Basic Information</h5>
<div class="row mb-3">
<div class="col-md-6">
<p class="mb-1"><strong>Name:</strong></p>
<p>@Model.Name</p>
</div>
<div class="col-md-6">
<p class="mb-1"><strong>SKU:</strong></p>
<p>@(Model.SKU ?? "-")</p>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<p class="mb-1"><strong>Category:</strong></p>
<p>
<span class="badge bg-secondary">@Model.FullCategoryPath</span>
</p>
</div>
<div class="col-md-6">
<p class="mb-1"><strong>Status:</strong></p>
<p>
@if (Model.IsActive)
{
<span class="badge bg-success">Active</span>
}
else
{
<span class="badge bg-secondary">Inactive</span>
}
</p>
</div>
</div>
@if (!string.IsNullOrWhiteSpace(Model.Description))
{
<div class="mb-3">
<p class="mb-1"><strong>Description:</strong></p>
<p>@Model.Description</p>
</div>
}
<!-- Pricing & Defaults -->
<h5 class="border-bottom pb-2 mb-3 mt-4">Pricing & Defaults</h5>
<div class="row mb-3">
<div class="col-md-6">
<p class="mb-1"><strong>Default Price:</strong></p>
<p class="h4 text-primary">@Model.DefaultPrice.ToString("C")</p>
</div>
<div class="col-md-6">
<p class="mb-1"><strong>Estimated Time:</strong></p>
<p>@(Model.DefaultEstimatedMinutes.HasValue ? $"{Model.DefaultEstimatedMinutes} minutes" : "-")</p>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<p class="mb-1"><strong>Processing Requirements:</strong></p>
<div>
@if (Model.DefaultRequiresSandblasting)
{
<span class="badge bg-warning text-dark me-1">
<i class="bi bi-exclamation-triangle me-1"></i>Sandblasting
</span>
}
@if (Model.DefaultRequiresMasking)
{
<span class="badge bg-info me-1">
<i class="bi bi-shield me-1"></i>Masking
</span>
}
@if (!Model.DefaultRequiresSandblasting && !Model.DefaultRequiresMasking)
{
<span class="text-muted">None specified</span>
}
</div>
</div>
<div class="col-md-6">
<p class="mb-1"><strong>Display Order:</strong></p>
<p>@Model.DisplayOrder</p>
</div>
</div>
@if (Model.RevenueAccountId.HasValue || Model.CogsAccountId.HasValue)
{
<h5 class="border-bottom pb-2 mb-3 mt-4">Financial Accounts</h5>
<div class="row mb-3">
<div class="col-md-6">
<p class="mb-1"><strong>Revenue Account:</strong></p>
<p>
@if (Model.RevenueAccountId.HasValue)
{
<span class="badge bg-info text-dark">@Model.RevenueAccountName</span>
}
else
{
<span class="text-muted small">Default</span>
}
</p>
</div>
<div class="col-md-6">
<p class="mb-1"><strong>COGS Account:</strong></p>
<p>
@if (Model.CogsAccountId.HasValue)
{
<span class="badge bg-warning text-dark">@Model.CogsAccountName</span>
}
else
{
<span class="text-muted small">Default</span>
}
</p>
</div>
</div>
}
</div>
</div>
</div>
<!-- Right Column: Actions -->
<div class="col-lg-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Actions</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-primary">
<i class="bi bi-pencil me-1"></i>Edit Item
</a>
<a asp-action="Delete" asp-route-id="@Model.Id" class="btn btn-outline-danger">
<i class="bi bi-trash me-1"></i>Delete Item
</a>
<hr />
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to Catalog
</a>
<a asp-action="Create" asp-route-categoryId="@Model.CategoryId" class="btn btn-outline-primary">
<i class="bi bi-plus-circle me-1"></i>Add Similar Item
</a>
</div>
</div>
</div>
<!-- Quick Stats Card -->
<div class="card mt-3">
<div class="card-header">
<h6 class="mb-0">Quick Info</h6>
</div>
<div class="card-body">
<ul class="list-unstyled mb-0">
<li class="mb-2">
<i class="bi bi-folder text-muted me-2"></i>
<strong>Category:</strong><br />
<small class="ms-4">@Model.CategoryName</small>
</li>
<li class="mb-2">
<i class="bi bi-cash text-muted me-2"></i>
<strong>Price:</strong><br />
<small class="ms-4">@Model.DefaultPrice.ToString("C")</small>
</li>
@if (Model.DefaultEstimatedMinutes.HasValue)
{
<li class="mb-2">
<i class="bi bi-clock text-muted me-2"></i>
<strong>Time:</strong><br />
<small class="ms-4">@Model.DefaultEstimatedMinutes min</small>
</li>
}
</ul>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,349 @@
@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">
<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>
<!-- 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-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);
}
}
});
</script>
}
@@ -0,0 +1,215 @@
@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 mb-4">
<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>
@@ -0,0 +1,90 @@
@model PowderCoating.Web.Controllers.CategoryWithItems
@{
var categoryId = Model.Category.Id;
var collapseId = $"category-{categoryId}";
var hasItems = Model.Items.Any();
var hasSubCategories = Model.SubCategories.Any();
}
<div class="category-node mb-2">
<!-- Category Header -->
<div class="category-header @(hasItems || hasSubCategories ? "" : "collapsed")"
data-bs-toggle="collapse"
data-bs-target="#@collapseId"
aria-expanded="@(hasItems || hasSubCategories ? "true" : "false")">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-2">
<i class="bi bi-folder-fill text-primary"></i>
<span class="fw-semibold">@Model.Category.Name</span>
<span class="badge bg-secondary">@Model.TotalItems items</span>
</div>
<div>
<i class="bi bi-chevron-down"></i>
</div>
</div>
</div>
<!-- Category Content (Items and Subcategories) -->
<div class="collapse @(hasItems || hasSubCategories ? "show" : "")" id="@collapseId">
<div class="category-content ms-3">
<!-- Items in this category -->
@if (hasItems)
{
<div class="items-list">
@foreach (var item in Model.Items)
{
<div class="item-row">
<div class="item-row-name">
<a asp-action="Details" asp-route-id="@item.Id" class="catalog-item-link">
<i class="bi bi-box me-2 catalog-text-muted"></i>@item.Name
</a>
</div>
<div class="item-row-meta">
<span class="catalog-price">@item.DefaultPrice.ToString("C")</span>
@if (item.IsActive)
{
<span class="badge bg-success">Active</span>
}
else
{
<span class="badge bg-secondary">Inactive</span>
}
<div class="btn-group btn-group-sm">
<a asp-action="Details" asp-route-id="@item.Id" class="btn btn-outline-primary btn-sm" title="View Details">
<i class="bi bi-eye"></i>
</a>
<a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-outline-secondary btn-sm" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<a asp-action="Delete" asp-route-id="@item.Id" class="btn btn-outline-danger btn-sm" title="Delete">
<i class="bi bi-trash"></i>
</a>
</div>
</div>
</div>
}
</div>
}
<!-- Subcategories -->
@if (hasSubCategories)
{
<div class="subcategory mt-2">
@foreach (var subCategory in Model.SubCategories)
{
<partial name="_CategoryNode" model="subCategory" />
}
</div>
}
<!-- Empty state for category -->
@if (!hasItems && !hasSubCategories)
{
<div class="no-items text-center py-3 catalog-text-muted">
<i class="bi bi-inbox"></i>
<p class="mb-0">No items in this category</p>
</div>
}
</div>
</div>
</div>