Add Custom Powder Order line item and fix CSV import FinalPrice crash

Custom powder/incoming powder material cost now flows into a separate
auto-generated 'Custom Powder Order' line item instead of rolling into
individual item prices, so users can add shipping charges before the
customer sees the total. A dashed yellow preview card in the wizard
shows the material cost and lets users edit the total (including shipping)
before saving. After first save the price is user-owned.

Also fixes a fatal CSV import crash when FinalPrice contains a non-numeric
value (e.g. 'false' from a spreadsheet formula): the job CSV importer now
streams rows one at a time with a lenient decimal converter, treating bad
values as $0 with a per-row warning instead of aborting the entire import.

Updated HelpKnowledgeBase.cs and Help articles (Jobs, Quotes) with
Custom Powder Order behavior and a new Data Import / Export section.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 23:37:46 -04:00
parent e476b4744d
commit a7ad0e1de8
19 changed files with 721 additions and 78 deletions
@@ -14,6 +14,13 @@
let quoteItems = []; // Array of item objects matching CreateQuoteItemDto shape
// Custom Powder Order preview state — tracks the auto-calculated material cost and any
// user override (e.g. adding shipping). Reset when the server says no powder is needed.
let _customPowderAutoAmount = 0; // last server-calculated material cost
let _customPowderUserAmount = null; // null = use auto; number = user override
let _customPowderPreviewLabel = 'Custom Powder Order';
let _pricingPayloadHadUserPowderItem = false; // set just before each pricing fetch
const wz = { // Wizard state
step: 1,
editIndex: -1, // -1 = new item; >= 0 = editing
@@ -903,7 +910,13 @@ function renderFormulaFields() {
</div>
</div>
<div id="wz_formula_fields">${fieldsHtml}</div>
<div id="err_formulaCalc" class="text-danger small mt-1 d-none">Please calculate the formula first.</div>`;
<div id="err_formulaCalc" class="text-danger small mt-1 d-none">Please calculate the formula first.</div>
<div class="form-text mt-2 text-muted" style="font-size:0.75rem;">
<i class="bi bi-info-circle me-1"></i>Shop rates automatically applied:
<code class="ms-1">standard_labor_rate</code>
<code class="ms-1">additional_coat_labor_pct</code>
<code class="ms-1">markup_pct</code>
</div>`;
}
window.wzFormulaTemplateChanged = function () {
@@ -2942,6 +2955,8 @@ function buildCardHtml(item, i) {
? `${item.quantity} hr${item.quantity !== 1 ? 's' : ''}`
: item.isSalesItem
? `$${fmtNum(item.manualUnitPrice)} × ${item.quantity}${item.sku ? ` · <span class="text-muted small">${escHtml(item.sku)}</span>` : ''}`
: item.isCustomFormulaItem && item.manualUnitPrice != null
? `Total: $${fmtNum(item.manualUnitPrice)}${item.quantity > 1 ? ` · Qty: ${item.quantity}` : ''}`
: item.surfaceAreaSqFt
? `${item.quantity} × ${fmtNum(item.surfaceAreaSqFt)} ${pageMeta.areaUnit || 'sq ft'}`
: `Qty: ${item.quantity}`;
@@ -3095,6 +3110,27 @@ function writeHiddenFields() {
});
});
// When a Custom Powder Order is pending (preview visible), include it as a submitted item
// so the server uses this price (user's override or auto amount) and skips auto-creation.
if (_customPowderAutoAmount > 0) {
const amount = _customPowderUserAmount ?? _customPowderAutoAmount;
const n = quoteItems.length;
const prefix = pageMeta.itemsFieldPrefix || 'QuoteItems';
const p = `${prefix}[${n}]`;
fields.push(h(p + '.Description', _customPowderPreviewLabel));
fields.push(h(p + '.Quantity', 1));
fields.push(h(p + '.IsGenericItem', 'true'));
fields.push(h(p + '.ManualUnitPrice', amount.toFixed(2)));
fields.push(h(p + '.SurfaceAreaSqFt', 0));
fields.push(h(p + '.EstimatedMinutes', 0));
fields.push(h(p + '.IsLaborItem', 'false'));
fields.push(h(p + '.IsSalesItem', 'false'));
fields.push(h(p + '.RequiresSandblasting', 'false'));
fields.push(h(p + '.RequiresMasking', 'false'));
fields.push(h(p + '.IncludePrepCost', 'false'));
fields.push(h(p + '.Complexity', 'Simple'));
}
container.innerHTML = fields.join('');
// Write all AI photo tempIds as top-level form fields for photo promotion on save
@@ -3140,8 +3176,24 @@ async function runAutoPricing() {
const ovenBatches = parseInt(document.getElementById('OvenBatches')?.value) || 1;
const ovenCycleMinutes = parseInt(document.getElementById('OvenCycleMinutes')?.value) || null;
// When the user has overridden the powder order price, inject it as a virtual
// Generic item so the server treats it as an existing Custom Powder Order and
// prices the total at the user's amount (including any added shipping).
const pricingItems = [...quoteItems];
_pricingPayloadHadUserPowderItem = false;
if (_customPowderAutoAmount > 0 && _customPowderUserAmount !== null) {
pricingItems.push({
description: _customPowderPreviewLabel,
isGenericItem: true,
manualUnitPrice: _customPowderUserAmount,
quantity: 1,
coats: []
});
_pricingPayloadHadUserPowderItem = true;
}
const payload = {
items: quoteItems,
items: pricingItems,
customerId,
taxPercent,
discountType,
@@ -3178,6 +3230,36 @@ function updatePricingDisplay(r) {
const show = (id, visible) => document.getElementById(id)?.classList.toggle('d-none', !visible);
const setText = (id, text) => { const el = document.getElementById(id); if (el) el.textContent = text; };
// Custom Powder Order preview — server returns amount > 0 only when powder must be ordered
// and no existing Custom Powder Order item is already in the submitted list.
if (r.customPowderOrderAmount > 0) {
_customPowderAutoAmount = r.customPowderOrderAmount;
const colors = r.customPowderOrderColors || [];
_customPowderPreviewLabel = colors.length > 0
? `Custom Powder Order (${colors.join(', ')})`
: 'Custom Powder Order';
} else if (!_pricingPayloadHadUserPowderItem) {
// Server found no powder and we didn't inject a user item — truly none needed
_customPowderAutoAmount = 0;
_customPowderUserAmount = null;
}
const hasPowderPreview = _customPowderAutoAmount > 0;
const preview = document.getElementById('customPowderOrderPreview');
if (preview) {
preview.classList.toggle('d-none', !hasPowderPreview);
if (hasPowderPreview) {
const descEl = document.getElementById('customPowderOrderPreviewDesc');
if (descEl) descEl.textContent = _customPowderPreviewLabel;
const priceEl = document.getElementById('customPowderOrderPreviewPrice');
if (priceEl) priceEl.textContent = 'Material: $' + fmtNum(_customPowderAutoAmount);
// Only reset the input when user hasn't overridden
if (_customPowderUserAmount === null) {
const input = document.getElementById('customPowderOrderPriceInput');
if (input) input.value = _customPowderAutoAmount.toFixed(2);
}
}
}
document.getElementById('pricingPlaceholder')?.classList.add('d-none');
show('itemsSubtotalRow', true);
setText('itemsSubtotalDisplay', '$' + fmtNum(r.itemsSubtotal));
@@ -3242,6 +3324,17 @@ function resetPricingDisplay() {
document.getElementById('pricingPlaceholder')?.classList.remove('d-none');
['itemsSubtotalRow','ovenBatchCostRow','pricingTierDiscountRow','quoteDiscountRow','rushFeeRow',
'shopSuppliesRow','subtotalRow','taxRow','pricingDivider','totalRow'].forEach(hide);
document.getElementById('customPowderOrderPreview')?.classList.add('d-none');
_customPowderAutoAmount = 0;
_customPowderUserAmount = null;
}
/// Called when the user edits the Custom Powder Order price input.
/// Stores the override and re-runs pricing so the total reflects the new amount.
function onCustomPowderPriceEdit(val) {
const v = parseFloat(val);
_customPowderUserAmount = (!val || isNaN(v) || v <= 0) ? null : v;
scheduleAutoPricing();
}
// ─── Wizard UI helpers ────────────────────────────────────────────────────────