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>
This commit is contained in:
2026-05-20 21:37:10 -04:00
parent 21b39161a3
commit a0bdd2b5b4
252 changed files with 1785 additions and 1633 deletions
@@ -1,4 +1,4 @@
@model PowderCoating.Application.DTOs.Quote.CreateQuoteDto
@model PowderCoating.Application.DTOs.Quote.CreateQuoteDto
@using PowderCoating.Core.Entities
@{
@@ -51,7 +51,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Customer vs Prospect/Walk-In"
data-bs-content="Choose &lt;strong&gt;Existing Customer&lt;/strong&gt; if this person is already in your system. Choose &lt;strong&gt;New Prospect/Walk-In&lt;/strong&gt; if they haven't committed yet their details stay on the quote. When they approve, you can convert them to a full customer record with one click.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#prospect-conversion' target='_blank'&gt;Learn more â†&lt;/a&gt;">
data-bs-content="Choose &lt;strong&gt;Existing Customer&lt;/strong&gt; if this person is already in your system. Choose &lt;strong&gt;New Prospect/Walk-In&lt;/strong&gt; if they haven't committed yet &mdash; their details stay on the quote. When they approve, you can convert them to a full customer record with one click.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#prospect-conversion' target='_blank'&gt;Learn more â†'&lt;/a&gt;">
<i class="bi bi-question-circle"></i>
</a>
</h5>
@@ -146,7 +146,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Quote Information"
data-bs-content="Set the quote date, expiration, and any internal notes. The &lt;strong&gt;Expiration Date&lt;/strong&gt; is shown to the customer once it passes the quote is flagged Expired and can no longer be approved without editing. The &lt;strong&gt;Customer PO&lt;/strong&gt; field is optional use it if the customer provides their own purchase order number.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-statuses' target='_blank'&gt;Learn more â†&lt;/a&gt;">
data-bs-content="Set the quote date, expiration, and any internal notes. The &lt;strong&gt;Expiration Date&lt;/strong&gt; is shown to the customer &mdash; once it passes the quote is flagged Expired and can no longer be approved without editing. The &lt;strong&gt;Customer PO&lt;/strong&gt; field is optional &mdash; use it if the customer provides their own purchase order number.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-statuses' target='_blank'&gt;Learn more â†'&lt;/a&gt;">
<i class="bi bi-question-circle"></i>
</a>
</h5>
@@ -210,7 +210,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Oven &amp; Batch Pricing"
data-bs-content="The oven cost is charged once per batch at the quote level, not per item. Estimate how many oven loads the full job will fill for example, if you have 20 small parts and your oven fits 10, that's 2 batches. Cycle time is how long each batch runs. The cost is calculated from your oven's hourly rate in Settings.">
data-bs-content="The oven cost is charged once per batch at the quote level, not per item. Estimate how many oven loads the full job will fill &mdash; for example, if you have 20 small parts and your oven fits 10, that's 2 batches. Cycle time is how long each batch runs. The cost is calculated from your oven's hourly rate in Settings.">
<i class="bi bi-question-circle"></i>
</a>
</h5>
@@ -253,7 +253,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Quote Item Types"
data-bs-content="&lt;strong&gt;Calculated&lt;/strong&gt; you enter surface area (sq ft) and the system prices it using your rates for materials, labour, and overhead.&lt;br&gt;&lt;strong&gt;Custom Work&lt;/strong&gt; you enter a description and a manual price. Use this for flat-rate jobs or work that doesn't fit the formula.&lt;br&gt;&lt;strong&gt;AI Photo&lt;/strong&gt; upload photos and let the AI estimate surface area and complexity for you.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-items' target='_blank'&gt;Learn more â†&lt;/a&gt;">
data-bs-content="&lt;strong&gt;Calculated&lt;/strong&gt; &mdash; you enter surface area (sq ft) and the system prices it using your rates for materials, labour, and overhead.&lt;br&gt;&lt;strong&gt;Custom Work&lt;/strong&gt; &mdash; you enter a description and a manual price. Use this for flat-rate jobs or work that doesn't fit the formula.&lt;br&gt;&lt;strong&gt;AI Photo&lt;/strong&gt; &mdash; upload photos and let the AI estimate surface area and complexity for you.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-items' target='_blank'&gt;Learn more â†'&lt;/a&gt;">
<i class="bi bi-question-circle"></i>
</a>
</h5>
@@ -314,7 +314,7 @@
<div class="form-check">
<input asp-for="HideDiscountFromCustomer" class="form-check-input" type="checkbox" id="hideDiscountFromCustomer" />
<label class="form-check-label small" for="hideDiscountFromCustomer">
Hide discount from customer PDFs and approval portal show final price only
Hide discount from customer &mdash; PDFs and approval portal show final price only
</label>
</div>
</div>
@@ -329,7 +329,7 @@
<a tabindex="0" class="help-icon text-white" role="button"
data-bs-toggle="popover" data-bs-placement="left"
data-bs-title="Pricing Summary"
data-bs-content="The total is built up from materials, labour, equipment time, overhead, and profit margin all based on the rates in Settings. A &lt;strong&gt;Tier Discount&lt;/strong&gt; appears automatically if the customer has a pricing tier assigned. A &lt;strong&gt;Rush Fee&lt;/strong&gt; is added when Rush Job is checked.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#pricing-breakdown' target='_blank'&gt;Learn more â†&lt;/a&gt;">
data-bs-content="The total is built up from materials, labour, equipment time, overhead, and profit margin &mdash; all based on the rates in Settings. A &lt;strong&gt;Tier Discount&lt;/strong&gt; appears automatically if the customer has a pricing tier assigned. A &lt;strong&gt;Rush Fee&lt;/strong&gt; is added when Rush Job is checked.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#pricing-breakdown' target='_blank'&gt;Learn more â†'&lt;/a&gt;">
<i class="bi bi-question-circle"></i>
</a>
</h5>
@@ -379,7 +379,7 @@
<div class="row g-3" id="stagedPhotoGrid"></div>
<div id="stagedPhotoUploadProgress" class="d-none mt-2">
<div class="progress"><div class="progress-bar progress-bar-striped progress-bar-animated" style="width:100%"></div></div>
<small class="text-muted">Uploading</small>
<small class="text-muted">Uploading&hellip;</small>
</div>
</div>
</div>
@@ -552,7 +552,7 @@
<script src="~/lib/tom-select/js/tom-select.complete.min.js"></script>
<script src="~/js/item-wizard.js?v=@DateTime.Now.Ticks"></script>
<script>
// ── Quick / Full quote mode toggle ──────────────────────────────────
// -- Quick / Full quote mode toggle ----------------------------------
(function () {
const STORAGE_KEY = 'pcl_quote_mode';
const form = document.getElementById('quoteForm');
@@ -561,7 +561,7 @@
function applyMode(mode) {
if (mode === 'simple') {
form.classList.add('quote-simple-mode');
hint.textContent = 'Advanced fields are hidden switch to Full Quote to see them.';
hint.textContent = 'Advanced fields are hidden &mdash; switch to Full Quote to see them.';
} else {
form.classList.remove('quote-simple-mode');
hint.textContent = '';
@@ -631,8 +631,8 @@
smsNote.style.display = 'inline';
smsNote.className = hasSms ? 'badge bg-info text-white' : 'badge bg-warning text-dark';
smsNote.innerHTML = hasSms
? '<i class="bi bi-phone me-1"></i>No email send via SMS from quote details'
: '<i class="bi bi-phone-slash me-1"></i>No email SMS consent required';
? '<i class="bi bi-phone me-1"></i>No email &mdash; send via SMS from quote details'
: '<i class="bi bi-phone-slash me-1"></i>No email &mdash; SMS consent required';
} else {
smsNote.style.display = 'none';
}
@@ -17,12 +17,12 @@
{
<div class="alert alert-warning alert-permanent">
<h5 class="alert-heading">
<i class="bi bi-lock-fill me-2"></i>Quote Is Locked Linked Job Exists
<i class="bi bi-lock-fill me-2"></i>Quote Is Locked &mdash; Linked Job Exists
</h5>
<p class="mb-1">
Quote <strong>@Model.QuoteNumber</strong> was used to create
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@linkedJobId" class="fw-semibold alert-link">@linkedJobNumber</a>.
It cannot be deleted while that job exists the quote is the original pricing and approval record for the job.
It cannot be deleted while that job exists &mdash; the quote is the original pricing and approval record for the job.
</p>
<p class="mb-0">To delete this quote, delete <strong>@linkedJobNumber</strong> first.</p>
</div>
@@ -20,7 +20,7 @@
<a tabindex="0" class="help-icon ms-1" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Quote Statuses"
data-bs-content="&lt;strong&gt;Draft&lt;/strong&gt; saved but not yet sent. Edit freely.&lt;br&gt;&lt;strong&gt;Sent&lt;/strong&gt; delivered to the customer, awaiting response.&lt;br&gt;&lt;strong&gt;Approved&lt;/strong&gt; customer accepted. You can convert this to a Job.&lt;br&gt;&lt;strong&gt;Rejected&lt;/strong&gt; customer declined.&lt;br&gt;&lt;strong&gt;Expired&lt;/strong&gt; validity period has passed. Edit to extend it.&lt;br&gt;&lt;strong&gt;Converted&lt;/strong&gt; a job has been created from this quote.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-statuses' target='_blank'&gt;Learn more →&lt;/a&gt;">
data-bs-content="&lt;strong&gt;Draft&lt;/strong&gt; &mdash; saved but not yet sent. Edit freely.&lt;br&gt;&lt;strong&gt;Sent&lt;/strong&gt; &mdash; delivered to the customer, awaiting response.&lt;br&gt;&lt;strong&gt;Approved&lt;/strong&gt; &mdash; customer accepted. You can convert this to a Job.&lt;br&gt;&lt;strong&gt;Rejected&lt;/strong&gt; &mdash; customer declined.&lt;br&gt;&lt;strong&gt;Expired&lt;/strong&gt; &mdash; validity period has passed. Edit to extend it.&lt;br&gt;&lt;strong&gt;Converted&lt;/strong&gt; &mdash; a job has been created from this quote.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-statuses' target='_blank'&gt;Learn more →&lt;/a&gt;">
<i class="bi bi-question-circle"></i>
</a>
</p>
@@ -334,7 +334,7 @@
}
else
{
<span class="badge bg-warning text-dark" style="font-size: 0.7em;" title="Custom powder must be purchased before coating">
<span class="badge bg-warning text-dark" style="font-size: 0.7em;" title="Custom powder &mdash; must be purchased before coating">
<i class="bi bi-cart me-1"></i>ORDER: @coat.PowderToOrder?.ToString("0.##") lbs
</span>
}
@@ -438,7 +438,7 @@
}
else
{
<span class="badge bg-warning text-dark" style="font-size: 0.7em;" title="Custom powder must be purchased before coating">
<span class="badge bg-warning text-dark" style="font-size: 0.7em;" title="Custom powder &mdash; must be purchased before coating">
<i class="bi bi-cart me-1"></i>ORDER: @coat.PowderToOrder?.ToString("0.##") lbs
</span>
}
@@ -463,7 +463,7 @@
<br />
<small class="ms-3">
• <strong>@(ps.PrepServiceName ?? $"Service #{ps.PrepServiceId}")</strong>
<span class="text-muted"> @ps.EstimatedMinutes min</span>
<span class="text-muted">&mdash; @ps.EstimatedMinutes min</span>
</small>
}
}
@@ -665,7 +665,7 @@
{
<div class="mb-1">
<strong>@(ps.PrepServiceName ?? $"Service #{ps.PrepServiceId}")</strong>
<span class="text-muted"> @ps.EstimatedMinutes min</span>
<span class="text-muted">&mdash; @ps.EstimatedMinutes min</span>
</div>
}
</span>
@@ -753,7 +753,7 @@
</div>
<div id="photoUploadProgress" class="d-none mt-2">
<div class="progress"><div class="progress-bar progress-bar-striped progress-bar-animated" style="width:100%"></div></div>
<small class="text-muted">Uploading</small>
<small class="text-muted">Uploading&hellip;</small>
</div>
</div>
</div>
@@ -1110,7 +1110,7 @@
var allCatalog = Model.QuoteItems != null && Model.QuoteItems.All(i => i.CatalogItemId.HasValue);
}
@* ── SECTION 1: Item Cost Breakdown ─────────────────────── *@
@* -- SECTION 1: Item Cost Breakdown ----------------------- *@
<div class="mb-3">
<div class="text-uppercase text-muted fw-semibold small mb-2" style="letter-spacing:.05em;">
<i class="bi bi-boxes me-1"></i>Item Costs
@@ -1140,7 +1140,7 @@
}
else if (allCatalog)
{
<div class="text-muted small fst-italic">All items use fixed catalog pricing no per-category cost split available.</div>
<div class="text-muted small fst-italic">All items use fixed catalog pricing &mdash; no per-category cost split available.</div>
}
else
{
@@ -1152,7 +1152,7 @@
</div>
</div>
@* ── SECTION 2: Quote-Level Additions ───────────────────── *@
@* -- SECTION 2: Quote-Level Additions --------------------- *@
@if (pb.OvenBatchCost > 0 || pb.FacilityOverheadCost > 0 || pb.ShopSuppliesAmount > 0 || pb.OverheadCosts > 0)
{
<div class="mb-3">
@@ -1190,7 +1190,7 @@
</div>
}
@* ── SECTION 3: Final Calculation ────────────────────────── *@
@* -- SECTION 3: Final Calculation -------------------------- *@
<div class="mb-2">
<div class="text-uppercase text-muted fw-semibold small mb-2" style="letter-spacing:.05em;">
<i class="bi bi-receipt me-1"></i>Final Calculation
@@ -1245,13 +1245,13 @@
var priceBeforeDiscount = pb.Total + pb.DiscountAmount;
var marginBeforeDiscount = priceBeforeDiscount > 0 ? ((priceBeforeDiscount - totalDirectCost) / priceBeforeDiscount * 100m) : 0m;
<div class="small text-muted">
Without discount: @marginBeforeDiscount.ToString("F1")% discount cost you @((marginBeforeDiscount - effectiveMargin).ToString("F1")) margin points
Without discount: @marginBeforeDiscount.ToString("F1")% &mdash; discount cost you @((marginBeforeDiscount - effectiveMargin).ToString("F1")) margin points
</div>
}
}
</div>
@* ── SECTION 4: Per-Item Cost Breakdown ─────────────────── *@
@* -- SECTION 4: Per-Item Cost Breakdown ------------------- *@
@{
var hasItemCostData = Model.QuoteItems != null && Model.QuoteItems.Any(i =>
i.ItemMaterialCost > 0 || i.ItemLaborCost > 0 || i.ItemEquipmentCost > 0);
@@ -1273,13 +1273,13 @@
var tier = ViewBag.PricingTier as PowderCoating.Core.Entities.PricingTier;
<div class="alert alert-success alert-permanent py-2 px-3 mb-3 small">
<i class="bi bi-tag me-1"></i>
<strong>Pricing Tier:</strong> @tier.TierName @tier.DiscountPercent% discount applied to this customer
<strong>Pricing Tier:</strong> @tier.TierName &mdash; @tier.DiscountPercent% discount applied to this customer
</div>
}
@if (!hasItemCostData)
{
<div class="text-muted small fst-italic mb-3">Per-item cost detail not available re-save this quote to capture the breakdown.</div>
<div class="text-muted small fst-italic mb-3">Per-item cost detail not available &mdash; re-save this quote to capture the breakdown.</div>
}
@* Per-item breakdown cards *@
@@ -1427,7 +1427,7 @@
<div class="d-flex justify-content-between text-muted ps-2 border-start border-2 mb-1">
<div>
@coat.CoatName
@if (!string.IsNullOrEmpty(coat.ColorName)) { <text> @coat.ColorName</text> }
@if (!string.IsNullOrEmpty(coat.ColorName)) { <text> &mdash; @coat.ColorName</text> }
@if (!string.IsNullOrEmpty(coat.ColorCode)) { <text> (@coat.ColorCode)</text> }
@if (coat.InventoryItemId.HasValue)
{
@@ -1467,11 +1467,11 @@
}
else if (item.CatalogItemId.HasValue && !item.Coats.Any())
{
<div class="px-3 py-2 text-muted fst-italic">Catalog fixed price no cost split available.</div>
<div class="px-3 py-2 text-muted fst-italic">Catalog fixed price &mdash; no cost split available.</div>
}
else if (item.IsAiItem)
{
<div class="px-3 py-2 text-muted fst-italic">AI-estimated price cost breakdown not applicable.</div>
<div class="px-3 py-2 text-muted fst-italic">AI-estimated price &mdash; cost breakdown not applicable.</div>
}
</div>
}
@@ -1480,7 +1480,7 @@
@if (hasItemCostData)
{
<div class="d-flex justify-content-between fw-semibold border-top pt-2 mt-1 small">
<span class="text-muted">All items material / labor / equipment / total</span>
<span class="text-muted">All items &mdash; material / labor / equipment / total</span>
<span>@pb.MaterialCosts.ToString("C") / @pb.LaborCosts.ToString("C") / @pb.EquipmentCosts.ToString("C") / @pb.ItemsSubtotal.ToString("C")</span>
</div>
}
@@ -1506,7 +1506,7 @@
</tr>
<tr>
<td class="ps-0">Markup</td>
<td class="text-end pe-0">@dbgCosts.GeneralMarkupPercentage.ToString("F1")% applied to material costs</td>
<td class="text-end pe-0">@dbgCosts.GeneralMarkupPercentage.ToString("F1")% &mdash; applied to material costs</td>
</tr>
<tr>
<td class="ps-0">Additional coat charge</td>
@@ -1598,7 +1598,7 @@
@if (!detHasMobile && !detHasEmail && !detProspectHasPhone && string.IsNullOrWhiteSpace(Model.ProspectEmail))
{
<div class="alert alert-warning alert-permanent py-1 px-2 small">
<i class="bi bi-exclamation-triangle me-1"></i>No email or mobile number on file update the customer record to send this quote electronically.
<i class="bi bi-exclamation-triangle me-1"></i>No email or mobile number on file &mdash; update the customer record to send this quote electronically.
</div>
}
@if (detHasMobile && !detHasSmsConsent)
@@ -1607,7 +1607,7 @@
}
@if (detProspectHasPhone && !Model.ProspectSmsConsent)
{
<div class="text-muted small"><i class="bi bi-phone-slash me-1"></i>SMS consent not recorded edit the quote to enable SMS for this prospect.</div>
<div class="text-muted small"><i class="bi bi-phone-slash me-1"></i>SMS consent not recorded &mdash; edit the quote to enable SMS for this prospect.</div>
}
@if (!Model.ConvertedToJobId.HasValue)
{
@@ -2412,7 +2412,7 @@
<td class="small">${escHtml(n.type.replace(/([A-Z])/g, ' $1').trim())}</td>
<td class="small"><i class="bi ${channelIcon} me-1"></i>${escHtml(n.channel)}</td>
<td class="small">${escHtml(n.recipientName)}<br><span class="text-muted">${escHtml(n.recipient)}</span></td>
<td class="small">${n.subject ? escHtml(n.subject) : '<span class="text-muted"></span>'}</td>
<td class="small">${n.subject ? escHtml(n.subject) : '<span class="text-muted">&mdash;</span>'}</td>
<td><span class="badge bg-${statusClass}">${escHtml(n.status)}</span></td>
</tr>${errorRow}`;
}).join('');
@@ -2449,7 +2449,7 @@
}
}
// ── Deposits ─────────────────────────────────────────────────────────
// -- Deposits ---------------------------------------------------------
function getAntiForgeryToken() {
return document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
}
@@ -2466,7 +2466,7 @@
}
if (errEl) errEl.classList.add('d-none');
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving'; }
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving&hellip;'; }
const params = new URLSearchParams(new FormData(form));
try {
+13 -13
View File
@@ -1,4 +1,4 @@
@model PowderCoating.Application.DTOs.Quote.UpdateQuoteDto
@model PowderCoating.Application.DTOs.Quote.UpdateQuoteDto
@using PowderCoating.Core.Entities
@{
@@ -109,7 +109,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Quote Information"
data-bs-content="Set the quote date, expiration, and any internal notes. The &lt;strong&gt;Expiration Date&lt;/strong&gt; is shown to the customer once it passes the quote is flagged Expired and can no longer be approved without editing. The &lt;strong&gt;Customer PO&lt;/strong&gt; field is optional use it if the customer provides their own purchase order number.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-statuses' target='_blank'&gt;Learn more â†&lt;/a&gt;">
data-bs-content="Set the quote date, expiration, and any internal notes. The &lt;strong&gt;Expiration Date&lt;/strong&gt; is shown to the customer &mdash; once it passes the quote is flagged Expired and can no longer be approved without editing. The &lt;strong&gt;Customer PO&lt;/strong&gt; field is optional &mdash; use it if the customer provides their own purchase order number.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-statuses' target='_blank'&gt;Learn more â†'&lt;/a&gt;">
<i class="bi bi-question-circle"></i>
</a>
</h5>
@@ -173,7 +173,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Oven &amp; Batch Pricing"
data-bs-content="The oven cost is charged once per batch at the quote level, not per item. Estimate how many oven loads the full job will fill for example, if you have 20 small parts and your oven fits 10, that's 2 batches. Cycle time is how long each batch runs. The cost is calculated from your oven's hourly rate in Settings.">
data-bs-content="The oven cost is charged once per batch at the quote level, not per item. Estimate how many oven loads the full job will fill &mdash; for example, if you have 20 small parts and your oven fits 10, that's 2 batches. Cycle time is how long each batch runs. The cost is calculated from your oven's hourly rate in Settings.">
<i class="bi bi-question-circle"></i>
</a>
</h5>
@@ -216,7 +216,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Quote Item Types"
data-bs-content="&lt;strong&gt;Calculated&lt;/strong&gt; you enter surface area (sq ft) and the system prices it using your rates for materials, labour, and overhead.&lt;br&gt;&lt;strong&gt;Custom Work&lt;/strong&gt; you enter a description and a manual price. Use this for flat-rate jobs or work that doesn't fit the formula.&lt;br&gt;&lt;strong&gt;AI Photo&lt;/strong&gt; upload photos and let the AI estimate surface area and complexity for you.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-items' target='_blank'&gt;Learn more â†&lt;/a&gt;">
data-bs-content="&lt;strong&gt;Calculated&lt;/strong&gt; &mdash; you enter surface area (sq ft) and the system prices it using your rates for materials, labour, and overhead.&lt;br&gt;&lt;strong&gt;Custom Work&lt;/strong&gt; &mdash; you enter a description and a manual price. Use this for flat-rate jobs or work that doesn't fit the formula.&lt;br&gt;&lt;strong&gt;AI Photo&lt;/strong&gt; &mdash; upload photos and let the AI estimate surface area and complexity for you.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-items' target='_blank'&gt;Learn more â†'&lt;/a&gt;">
<i class="bi bi-question-circle"></i>
</a>
</h5>
@@ -277,7 +277,7 @@
<div class="form-check">
<input asp-for="HideDiscountFromCustomer" class="form-check-input" type="checkbox" id="hideDiscountFromCustomer" />
<label class="form-check-label small" for="hideDiscountFromCustomer">
Hide discount from customer PDFs and approval portal show final price only
Hide discount from customer &mdash; PDFs and approval portal show final price only
</label>
</div>
</div>
@@ -292,7 +292,7 @@
<a tabindex="0" class="help-icon text-white" role="button"
data-bs-toggle="popover" data-bs-placement="left"
data-bs-title="Pricing Summary"
data-bs-content="The total is built up from materials, labour, equipment time, overhead, and profit margin all based on the rates in Settings. A &lt;strong&gt;Tier Discount&lt;/strong&gt; appears automatically if the customer has a pricing tier assigned. A &lt;strong&gt;Rush Fee&lt;/strong&gt; is added when Rush Job is checked.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#pricing-breakdown' target='_blank'&gt;Learn more â†&lt;/a&gt;">
data-bs-content="The total is built up from materials, labour, equipment time, overhead, and profit margin &mdash; all based on the rates in Settings. A &lt;strong&gt;Tier Discount&lt;/strong&gt; appears automatically if the customer has a pricing tier assigned. A &lt;strong&gt;Rush Fee&lt;/strong&gt; is added when Rush Job is checked.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#pricing-breakdown' target='_blank'&gt;Learn more â†'&lt;/a&gt;">
<i class="bi bi-question-circle"></i>
</a>
</h5>
@@ -407,7 +407,7 @@
</div>
<div id="editPhotoUploadProgress" class="d-none mt-2">
<div class="progress"><div class="progress-bar progress-bar-striped progress-bar-animated" style="width:100%"></div></div>
<small class="text-muted">Uploading</small>
<small class="text-muted">Uploading&hellip;</small>
</div>
</div>
</div>
@@ -435,11 +435,11 @@
<span id="smsNotifyNote" class="badge @(editHasSms ? "bg-info text-white" : "bg-warning text-dark")">
@if (editHasSms)
{
<i class="bi bi-phone me-1"></i><text>No email send via SMS from quote details</text>
<i class="bi bi-phone me-1"></i><text>No email &mdash; send via SMS from quote details</text>
}
else
{
<i class="bi bi-phone-slash me-1"></i><text>No email SMS consent required</text>
<i class="bi bi-phone-slash me-1"></i><text>No email &mdash; SMS consent required</text>
}
</span>
}
@@ -488,7 +488,7 @@
@Html.Raw(Json.Serialize(ViewBag.BlastSetups ?? new List<object>()))
</script>
<!-- Existing items always populated on Edit -->
<!-- Existing items &mdash; always populated on Edit -->
<script id="existingItemsData" type="application/json">
@Html.Raw(System.Text.Json.JsonSerializer.Serialize((Model.QuoteItems ?? new List<PowderCoating.Application.DTOs.Quote.CreateQuoteItemDto>()).Select((item, i) => new {
description = item.Description,
@@ -614,8 +614,8 @@
smsNote.style.display = 'inline';
smsNote.className = hasSms ? 'badge bg-info text-white' : 'badge bg-warning text-dark';
smsNote.innerHTML = hasSms
? '<i class="bi bi-phone me-1"></i>No email send via SMS from quote details'
: '<i class="bi bi-phone-slash me-1"></i>No email SMS consent required';
? '<i class="bi bi-phone me-1"></i>No email &mdash; send via SMS from quote details'
: '<i class="bi bi-phone-slash me-1"></i>No email &mdash; SMS consent required';
} else {
smsNote.style.display = 'none';
}
@@ -640,7 +640,7 @@
}
});
// Quote photo direct upload (Edit page quoteId is known)
// Quote photo direct upload (Edit page &mdash; quoteId is known)
(function () {
const quoteId = @Model.Id;
const uploadUrl = '@Url.Action("UploadQuotePhoto", "Quotes")';
@@ -21,7 +21,7 @@
<i class="bi bi-speedometer2 fs-5 flex-shrink-0 mt-1"></i>
<div>
<strong>Your quoting isn't calibrated yet.</strong>
AI photo quotes and calculated item time estimates are using generic industry averages they may not match your shop's actual throughput.
AI photo quotes and calculated item time estimates are using generic industry averages &mdash; they may not match your shop's actual throughput.
<a href="/CompanySettings#quoting-calibration" class="alert-link ms-1">Set up your equipment profile →</a>
</div>
<button type="button" class="btn-close ms-auto flex-shrink-0" data-bs-dismiss="alert" aria-label="Close"></button>