Files
PowderCoatingLogix/src/PowderCoating.Web/Views/CatalogItems/Create.cshtml
T
spouliot a0bdd2b5b4 Sweep all .cshtml files for encoding corruption; add pre-commit guard
Replace all corruption variants with HTML entities across 226 view files:
- 3-char UTF-8-as-Win1252 sequences (ae-corruption)
- Standalone smart/curly quotes that break C# Razor expressions
- Partially re-corrupted variants where the 3rd byte was normalised to ASCII

tools/Fix-Encoding.ps1: re-runnable sweep; uses [char] code points so the
script itself never contains a literal non-ASCII character; supports -DryRun

.githooks/pre-commit: blocks commits containing the ae-corruption byte
signature (xc3xa2xe2x82xac); git core.hooksPath = .githooks so the
hook is repo-committed and active for all future work on this machine.

Build clean; 225 unit tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 21:37:10 -04:00

377 lines
23 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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 &mdash; 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 &mdash; 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 &mdash; no markup, prep services, or complexity adjustments are applied on top. Set the all-in price you want to bill. Approximate Area is optional &mdash; 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 &mdash; 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 &mdash; 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&hellip;</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 &mdash; 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>
}