Files
PowderCoatingLogix/src/PowderCoating.Web/Views/Quotes/Edit.cshtml
T
spouliot a7ad0e1de8 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>
2026-05-25 23:37:46 -04:00

798 lines
47 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.Quote.UpdateQuoteDto
@using PowderCoating.Core.Entities
@{
ViewData["Title"] = "Edit Quote";
ViewData["PageIcon"] = "bi-pencil-fill";
}
<div class="container-fluid mt-4">
<div class="d-flex justify-content-end gap-2 mb-4">
<a asp-controller="Help" asp-action="Quotes" class="btn btn-outline-secondary" target="_blank" title="Quotes help">
<i class="bi bi-question-circle me-1"></i>Help
</a>
<a asp-action="Details" asp-controller="Quotes" asp-route-id="@Model.Id" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to Details
</a>
</div>
<form asp-action="Edit" asp-controller="Quotes" method="post" id="quoteForm">
@Html.AntiForgeryToken()
<input type="hidden" asp-for="Id" />
<input type="hidden" asp-for="TaxPercent" />
<input type="hidden" asp-for="QuoteStatusId" />
<partial name="_ValidationSummary" />
<!-- Section 1: Customer / Prospect/Walk-In -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-person-circle me-2"></i>Customer / Prospect/Walk-In</h5>
</div>
<div class="card-body">
<input type="hidden" asp-for="IsForProspect" />
@if (Model.IsForProspect)
{
<!-- Prospect Information (Editable) -->
<div class="row">
<div class="col-md-6">
<label asp-for="ProspectCompanyName" class="form-label"></label>
<input asp-for="ProspectCompanyName" class="form-control" />
</div>
<div class="col-md-6">
<label asp-for="ProspectContactName" class="form-label">Contact Name <span class="text-danger">*</span></label>
<input asp-for="ProspectContactName" class="form-control" />
<span asp-validation-for="ProspectContactName" class="text-danger"></span>
</div>
</div>
<div class="row mt-2">
<div class="col-md-6">
<label asp-for="ProspectEmail" class="form-label"></label>
<input asp-for="ProspectEmail" class="form-control" type="email" />
</div>
<div class="col-md-6">
<label asp-for="ProspectPhone" class="form-label">Phone <span class="text-danger">*</span></label>
<input asp-for="ProspectPhone" class="form-control" type="tel" id="editProspectPhone" />
<span asp-validation-for="ProspectPhone" class="text-danger"></span>
</div>
</div>
<div class="row mt-2" id="editProspectSmsConsentRow">
<div class="col-12">
<div class="form-check">
<input asp-for="ProspectSmsConsent" class="form-check-input" id="ProspectSmsConsent" />
<label class="form-check-label fw-semibold" for="ProspectSmsConsent">
Customer verbally agreed to receive SMS notifications
</label>
</div>
<small class="text-muted">
<i class="bi bi-shield-check me-1"></i>Only check if the customer explicitly consented to receive text messages (TCPA compliance).
</small>
</div>
</div>
<div class="row mt-2">
<div class="col-md-6">
<label asp-for="ProspectAddress" class="form-label"></label>
<input asp-for="ProspectAddress" class="form-control" />
</div>
<div class="col-md-3">
<label asp-for="ProspectCity" class="form-label"></label>
<input asp-for="ProspectCity" class="form-control" />
</div>
<div class="col-md-1">
<label asp-for="ProspectState" class="form-label"></label>
<input asp-for="ProspectState" class="form-control" maxlength="2" />
</div>
<div class="col-md-2">
<label asp-for="ProspectZipCode" class="form-label"></label>
<input asp-for="ProspectZipCode" class="form-control" />
</div>
</div>
}
else
{
<!-- Customer Dropdown (now editable) -->
<div class="col-md-6">
<label asp-for="CustomerId" class="form-label fw-semibold">Customer</label>
<select asp-for="CustomerId" asp-items="ViewBag.Customers" id="customerSelect" class="form-select">
<option value="">-- Select Customer --</option>
</select>
<span asp-validation-for="CustomerId" class="text-danger"></span>
</div>
}
</div>
</div>
<!-- Section 2: Quote Information -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-info-circle me-2"></i>Quote Information
<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 &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>
</div>
<div class="card-body">
<div class="mb-3">
<div class="form-check">
<input asp-for="IsRushJob" class="form-check-input" type="checkbox" id="IsRushJob" onchange="scheduleAutoPricing()">
<label class="form-check-label" for="IsRushJob">
<strong>Rush Job</strong> <small class="text-muted">(additional fee applies)</small>
</label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Rush Job"
data-bs-content="Marks this quote as high priority. A rush fee is added to the pricing total based on the rate configured in Settings. The job will also be highlighted in the jobs list so your team knows to prioritise it.">
<i class="bi bi-question-circle"></i>
</a>
</div>
</div>
<div class="row">
<div class="col-md-6">
<label asp-for="QuoteDate" class="form-label"></label>
<input asp-for="QuoteDate" class="form-control" type="date" />
<span asp-validation-for="QuoteDate" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="ExpirationDate" class="form-label"></label>
<input asp-for="ExpirationDate" class="form-control" type="date" />
</div>
</div>
<div class="row mt-2">
<div class="col-md-6">
<label asp-for="Description" class="form-label"></label>
<input asp-for="Description" class="form-control" />
</div>
<div class="col-md-6">
<label asp-for="CustomerPO" class="form-label"></label>
<input asp-for="CustomerPO" class="form-control" />
</div>
</div>
<div class="row mt-2">
<div class="col-md-6">
<label asp-for="Notes" class="form-label"></label>
<textarea asp-for="Notes" class="form-control" rows="2"></textarea>
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">Tags <span class="badge rounded-pill bg-warning text-dark ms-1" style="font-size:0.65rem;">Recommended</span></label>
<p class="text-muted small mb-1">Tags are used to improve smart predictions and assistance over time. The more consistently you tag, the smarter the system gets.</p>
<input type="hidden" asp-for="Tags" id="quoteTags" />
<div id="quoteTagsContainer"></div>
<small class="text-muted">Press <kbd>Enter</kbd> or <kbd>,</kbd> to add a tag. Click &times; to remove.</small>
</div>
</div>
</div>
</div>
<!-- Section 3: Oven & Batch Pricing -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-fire me-2"></i>Oven &amp; Batch Pricing
<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 &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>
</div>
<div class="card-body">
<p class="text-muted small mb-3">Estimate how many oven loads the complete job will require. The oven cycle cost is added once at the quote level, not per item.</p>
<div class="row g-3 align-items-end">
@if (ViewBag.OvenCosts != null && ((List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.OvenCosts).Count > 1)
{
<div class="col-md-4">
<label asp-for="OvenCostId" class="form-label fw-semibold"><i class="bi bi-thermometer-half me-1"></i>Oven</label>
<select asp-for="OvenCostId" asp-items="@((List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.OvenCosts)"
class="form-select" onchange="scheduleAutoPricing()"></select>
</div>
}
<div class="col-sm-3 col-md-2">
<label asp-for="OvenBatches" class="form-label fw-semibold">Batches</label>
<input asp-for="OvenBatches" class="form-control" type="number" min="1"
value="@(Model.OvenBatches > 0 ? Model.OvenBatches : 1)"
id="OvenBatches" onchange="scheduleAutoPricing()" />
</div>
<div class="col-sm-4 col-md-3">
<label asp-for="OvenCycleMinutes" class="form-label fw-semibold">
Cycle Time per Batch (min)
<small class="text-muted fw-normal">default: @(ViewBag.DefaultOvenCycleMinutes ?? 45)</small>
</label>
<input asp-for="OvenCycleMinutes" class="form-control" type="number" min="1"
id="OvenCycleMinutes" placeholder="@(ViewBag.DefaultOvenCycleMinutes ?? 45)"
onchange="scheduleAutoPricing()" />
</div>
</div>
</div>
</div>
<!-- Section 4: Quote Items (Wizard) -->
<div class="card mb-4">
<div class="card-header d-flex align-items-center justify-content-between">
<h5 class="mb-0">
<i class="bi bi-list-ul me-2"></i>Quote Items
<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; &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>
<button type="button" class="btn btn-primary" onclick="openWizard()">
<i class="bi bi-plus-circle me-1"></i>Add Item
</button>
</div>
<div class="card-body">
<div id="itemsEmptyMessage" class="text-center py-5 text-muted" style="display: none;">
<i class="bi bi-box-seam display-4 d-block mb-2 opacity-25"></i>
<p class="mb-0">No items added yet.</p>
<p class="small">Click <strong>Add Item</strong> to get started.</p>
</div>
<div id="itemCardsContainer"></div>
<div id="customPowderOrderPreview" class="d-none mt-2">
<div class="quote-item-card border-warning" style="border-style:dashed!important;background:var(--bs-warning-bg-subtle,#fff8e1);">
<div class="d-flex align-items-start gap-2">
<div class="flex-grow-1">
<div class="d-flex align-items-center flex-wrap gap-1 mb-1">
<span class="badge bg-warning text-dark item-badge"><i class="bi bi-truck me-1"></i>Powder Order</span>
<span class="fw-semibold" id="customPowderOrderPreviewDesc">Custom Powder Order</span>
</div>
<div class="text-muted small"><span id="customPowderOrderPreviewPrice"></span> &mdash; edit total to include shipping</div>
</div>
<div class="text-center flex-shrink-0" style="min-width:45px;">1</div>
<div class="flex-shrink-0" style="min-width:90px;">
<input type="number" id="customPowderOrderPriceInput"
class="form-control form-control-sm text-end"
min="0" step="0.01" placeholder="0.00"
title="Enter total including any shipping"
oninput="onCustomPowderPriceEdit(this.value)">
</div>
<div style="min-width:66px;"></div>
</div>
</div>
</div>
<!-- Hidden fields written by wizard JS -->
<div id="hiddenFieldsContainer"></div>
<!-- AI photo temp IDs written by wizard JS -->
<div id="aiPhotoTempIdsContainer"></div>
<!-- Quote photo temp IDs written by staging JS -->
<div id="quotePhotoTempIdsContainer"></div>
</div>
</div>
<!-- Section 4: Discount -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-tag me-2"></i>Discount <small class="text-muted fw-normal">(optional)</small>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Quote Discount"
data-bs-content="Apply a one-off discount to this quote. Choose &lt;strong&gt;Percentage&lt;/strong&gt; to reduce the total by a % (e.g., 10%), or &lt;strong&gt;Fixed Amount&lt;/strong&gt; to deduct a set dollar value. This is separate from any pricing tier discount the customer automatically receives. Add a reason so your team knows why the discount was given.">
<i class="bi bi-question-circle"></i>
</a>
</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label asp-for="DiscountType" class="form-label fw-semibold">Discount Type</label>
<select asp-for="DiscountType" class="form-select" id="discountTypeSelect" onchange="onDiscountTypeChange(); scheduleAutoPricing()">
<option value="None">No Discount</option>
<option value="Percentage">Percentage (%)</option>
<option value="FixedAmount">Fixed Amount ($)</option>
</select>
</div>
<div class="col-md-4" id="discountValueSection" style="display: @(Model.DiscountType == "None" ? "none" : "block")">
<label class="form-label fw-semibold">Discount Value</label>
<input asp-for="DiscountValue" type="number" class="form-control" id="discountValueInput"
min="0" step="0.01" onchange="scheduleAutoPricing()" />
</div>
<div class="col-md-4" id="discountReasonSection" style="display: @(Model.DiscountType == "None" ? "none" : "block")">
<label asp-for="DiscountReason" class="form-label fw-semibold">Reason</label>
<input asp-for="DiscountReason" class="form-control" />
</div>
</div>
<div id="hideDiscountSection" class="mt-2" style="display: @(Model.DiscountType == "None" ? "none" : "block")">
<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 &mdash; PDFs and approval portal show final price only
</label>
</div>
</div>
</div>
</div>
<!-- Section 5: Pricing Summary -->
<div class="card mb-4">
<div class="card-header bg-primary text-white d-flex align-items-center justify-content-between">
<h5 class="mb-0">
<i class="bi bi-calculator me-2"></i>Pricing Summary
<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 &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>
<span id="pricingSpinner" class="spinner-border spinner-border-sm text-white d-none" role="status"></span>
</div>
<div class="card-body">
<div class="text-end" id="pricingSummary">
<p class="mb-1 text-muted small" id="pricingPlaceholder">Pricing will update automatically as you add items.</p>
<p class="mb-1 d-none" id="itemsSubtotalRow">Items Subtotal: <strong id="itemsSubtotalDisplay">$0.00</strong></p>
<p class="mb-1 d-none" id="ovenBatchCostRow">
<i class="bi bi-fire me-1"></i>Oven (<span id="ovenBatchesDisplay">1</span> batch × <span id="ovenCycleMinDisplay">45</span> min):
<strong id="ovenBatchCostDisplay">$0.00</strong>
</p>
<p class="mb-1 text-success d-none" id="pricingTierDiscountRow">
Tier Discount (<span id="pricingTierDiscountPercentDisplay">0</span>%):
<strong id="pricingTierDiscountDisplay">-$0.00</strong>
</p>
<p class="mb-1 text-success d-none" id="quoteDiscountRow">
Quote Discount (<span id="quoteDiscountPercentDisplay">0</span>%):
<strong id="quoteDiscountDisplay">-$0.00</strong>
</p>
<p class="mb-1 text-warning d-none" id="rushFeeRow">
<i class="bi bi-lightning-fill me-1"></i>Rush Fee: <strong id="rushFeeDisplay">$0.00</strong>
</p>
<p class="mb-1 d-none" id="shopSuppliesRow">Shop Supplies (<span id="shopSuppliesPercentDisplay">0</span>%): <strong id="shopSuppliesDisplay">$0.00</strong></p>
<p class="mb-1 d-none" id="subtotalRow">Subtotal: <strong id="subtotalDisplay">$0.00</strong></p>
<p class="mb-1 d-none" id="taxRow">Tax (<span id="taxPercentDisplay">0</span>%): <strong id="taxDisplay">$0.00</strong></p>
<hr class="my-2 d-none" id="pricingDivider" />
<h5 class="mb-0 d-none" id="totalRow">Total: <strong id="totalDisplay" class="text-primary">$0.00</strong></h5>
</div>
</div>
</div>
<!-- Section 6: Quote Photos -->
@{
var editAllPhotos = ViewBag.QuotePhotos as List<PowderCoating.Core.Entities.QuotePhoto> ?? new List<PowderCoating.Core.Entities.QuotePhoto>();
bool editCanUpload = ViewBag.CanUploadQuotePhoto is bool eb && eb;
int editPhotoUsed = ViewBag.QuotePhotoUsed is int eu ? eu : 0;
int editPhotoMax = ViewBag.QuotePhotoMax is int em ? em : -1;
}
@if (editPhotoMax != 0)
{
<div class="card mb-4" id="quotePhotosCard">
<div class="card-header bg-white d-flex align-items-center justify-content-between">
<h5 class="mb-0"><i class="bi bi-images me-2 text-secondary"></i>Photos <span class="badge bg-secondary ms-1" id="photoCount">@editAllPhotos.Count</span></h5>
<div class="d-flex align-items-center gap-2">
@if (editPhotoMax > 0)
{
<small class="text-muted">@editPhotoUsed / @editPhotoMax</small>
}
@if (editCanUpload)
{
<button type="button" class="btn btn-sm btn-outline-primary" onclick="document.getElementById('editPhotoFileInput').click()">
<i class="bi bi-cloud-upload me-1"></i>Upload Photo
</button>
<input type="file" id="editPhotoFileInput" accept="image/*" class="d-none" multiple>
}
else if (editPhotoMax > 0 && editPhotoUsed >= editPhotoMax)
{
<span class="badge bg-warning text-dark">Limit reached</span>
}
</div>
</div>
<div class="card-body">
@if (!editAllPhotos.Any())
{
<p class="text-muted small mb-0" id="noPhotosMsg">No photos yet. Upload photos to attach them to this quote.</p>
}
<div class="row g-3" id="editPhotoGrid">
@foreach (var photo in editAllPhotos)
{
<div class="col-sm-6 col-md-4 col-lg-3 photo-item" id="photo-@photo.Id">
<div class="card h-100 position-relative">
@if (photo.IsAiAnalysisPhoto)
{
<span class="badge bg-secondary position-absolute top-0 start-0 m-1" style="z-index:1;font-size:.65rem;">AI</span>
}
else
{
<button type="button" class="btn btn-sm btn-danger position-absolute top-0 end-0 m-1 p-0 px-1 delete-photo-btn"
style="z-index:1;font-size:.7rem;" data-photo-id="@photo.Id" title="Delete">
<i class="bi bi-x"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary position-absolute top-0 start-0 m-1 p-0 px-1 edit-caption-btn"
style="z-index:1;font-size:.7rem;" data-photo-id="@photo.Id" title="Edit caption">
<i class="bi bi-pencil"></i>
</button>
}
<a href="@Url.Action("Photo", "Quotes", new { id = photo.Id })" target="_blank" title="View full size">
<img src="@Url.Action("Photo", "Quotes", new { id = photo.Id })"
class="card-img-top" style="height:160px;object-fit:cover;"
alt="@photo.FileName" loading="lazy">
</a>
<div class="card-body p-2">
<p class="card-text small text-muted text-truncate mb-0" title="@photo.FileName">@photo.FileName</p>
<p class="card-text text-muted mb-0" style="font-size:.7rem;">@photo.CreatedAt.ToString("MMM d, yyyy")</p>
@if (!photo.IsAiAnalysisPhoto)
{
<p class="card-text small fst-italic text-truncate mb-0 caption-display" title="@photo.Caption">@photo.Caption</p>
<div class="caption-edit-form d-none mt-1" data-photo-id="@photo.Id">
<textarea class="form-control form-control-sm caption-textarea" rows="2" placeholder="Add a caption...">@photo.Caption</textarea>
<div class="d-flex gap-1 mt-1">
<button type="button" class="btn btn-primary btn-sm save-caption-btn flex-grow-1">Save</button>
<button type="button" class="btn btn-outline-secondary btn-sm cancel-caption-btn">Cancel</button>
</div>
</div>
}
</div>
</div>
</div>
}
</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&hellip;</small>
</div>
</div>
</div>
}
<!-- Form Actions -->
<div class="card mb-4">
<div class="card-body d-flex align-items-center justify-content-end flex-wrap gap-3">
@{
var editHasEmail = !string.IsNullOrWhiteSpace(ViewBag.CustomerEmail as string);
var editHasSms = (bool)(ViewBag.CustomerNotifyBySms ?? false) && !string.IsNullOrWhiteSpace(ViewBag.CustomerMobilePhone as string);
}
<div class="d-flex align-items-center gap-1" id="notifyCustomerSection">
<div id="emailNotifySection" style="@(editHasEmail ? "" : "display:none;")">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" role="switch"
id="SendEmailToCustomer" name="SendEmailToCustomer" value="true" />
<label class="form-check-label fw-semibold" for="SendEmailToCustomer">
<i class="bi bi-envelope me-1"></i>Send updated quote via email
</label>
</div>
</div>
@if (!editHasEmail)
{
<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 &mdash; send via SMS from quote details</text>
}
else
{
<i class="bi bi-phone-slash me-1"></i><text>No email &mdash; SMS consent required</text>
}
</span>
}
else
{
<span id="smsNotifyNote" style="display:none;"></span>
}
</div>
<a asp-action="Details" asp-controller="Quotes" asp-route-id="@Model.Id" class="btn btn-outline-secondary btn-lg">
<i class="bi bi-x-circle me-1"></i>Cancel
</a>
<button type="submit" class="btn btn-primary btn-lg" id="submitBtn" onclick="if(typeof writeHiddenFields==='function')writeHiddenFields()">
<i class="bi bi-check-circle me-1"></i>Update Quote
</button>
</div>
</div>
</form>
</div>
@await Html.PartialAsync("_SqFtCalculatorModal")
@await Html.PartialAsync("_ItemWizardModal")
<!-- Embedded data for JS -->
@if (ViewBag.InventoryCoatings != null)
{
<script id="inventoryPowdersData" type="application/json">
@Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.InventoryCoatings))
</script>
}
@if (ViewBag.CatalogItems != null)
{
<script id="catalogItemsData" type="application/json">
@Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.CatalogItems))
</script>
}
<script id="merchandiseItemsData" type="application/json">
@Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.MerchandiseItems ?? new List<object>()))
</script>
<script id="vendorsData" type="application/json">
@Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.Vendors ?? new List<object>()))
</script>
<script id="prepServicesData" type="application/json">
@Html.Raw(Json.Serialize(((List<PrepService>)(ViewBag.PrepServices ?? new List<PrepService>())).Select(p => new { id = p.Id, name = p.ServiceName, description = p.Description, requiresBlastSetup = p.RequiresBlastSetup })))
</script>
<script id="blastSetupsData" type="application/json">
@Html.Raw(Json.Serialize(ViewBag.BlastSetups ?? new List<object>()))
</script>
<!-- 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,
quantity = item.Quantity,
surfaceAreaSqFt = item.SurfaceAreaSqFt,
estimatedMinutes = item.EstimatedMinutes,
catalogItemId = item.CatalogItemId,
manualUnitPrice = item.ManualUnitPrice,
powderCostOverride = item.PowderCostOverride,
isGenericItem = item.IsGenericItem,
isLaborItem = item.IsLaborItem,
isSalesItem = item.IsSalesItem,
isAiItem = item.IsAiItem,
isCustomFormulaItem = item.IsCustomFormulaItem,
customItemTemplateId = item.CustomItemTemplateId,
formulaFieldValuesJson = item.FormulaFieldValuesJson,
includePrepCost = item.IncludePrepCost,
complexity = item.Complexity,
aiTags = item.AiTags,
aiPredictionId = item.AiPredictionId,
requiresSandblasting = item.RequiresSandblasting,
requiresMasking = item.RequiresMasking,
notes = item.Notes,
prepServices = item.PrepServices.Select(ps => new {
prepServiceId = ps.PrepServiceId,
estimatedMinutes = ps.EstimatedMinutes,
blastSetupId = ps.BlastSetupId
}),
coats = item.Coats.Select(c => new {
coatName = c.CoatName,
sequence = c.Sequence,
inventoryItemId = c.InventoryItemId,
colorName = c.ColorName,
supplierId = c.VendorId,
colorCode = c.ColorCode,
finish = c.Finish,
coverageSqFtPerLb = c.CoverageSqFtPerLb,
transferEfficiency = c.TransferEfficiency,
powderCostPerLb = c.PowderCostPerLb,
powderToOrder = c.PowderToOrder,
notes = c.Notes,
noExtraLayerCharge = c.NoExtraLayerCharge
})
})))
</script>
<script id="quoteMetaData" type="application/json">
{
"quoteId": @Model.Id,
"customerId": @Json.Serialize(Model.CustomerId),
"taxPercent": @Model.TaxPercent,
"companyTaxPercent": @((ViewBag.CompanyTaxPercent ?? Model.TaxPercent).ToString()),
"taxExemptCustomerIds": @Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.CustomerTaxExemptIds ?? new System.Collections.Generic.HashSet<int>())),
"discountType": @Json.Serialize(Model.DiscountType),
"discountValue": @Model.DiscountValue,
"isRushJob": @Json.Serialize(Model.IsRushJob),
"ovenCostId": @Json.Serialize(Model.OvenCostId),
"areaUnit": @Json.Serialize((string?)ViewBag.AreaUnit),
"useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)),
"pricingUrl": "@Url.Action("CalculatePricing", "Quotes")",
"aiUploadUrl": "@Url.Action("UploadAiPhoto", "Quotes")",
"aiAnalyzeUrl": "@Url.Action("AiAnalyzeItem", "Quotes")",
"aiPhotoQuotesEnabled": @Json.Serialize((bool)(ViewBag.AiPhotoQuotesEnabled ?? true)),
"customFormulaTemplates": @Json.Serialize(ViewBag.CustomFormulaTemplates ?? new List<object>()),
"formulaEvalUrl": "@Url.Action("EvaluateFormula", "CompanySettings")",
"itemsFieldPrefix": "QuoteItems",
"aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")",
"emailOptOutCustomerIds": @Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.CustomerEmailOptOutIds ?? new System.Collections.Generic.HashSet<int>())),
"noEmailCustomerIds": @Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.CustomerNoEmailIds ?? new System.Collections.Generic.HashSet<int>())),
"smsConsentCustomerIds": @Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.CustomerSmsConsentIds ?? new System.Collections.Generic.HashSet<int>()))
}
</script>
@section Styles {
<link rel="stylesheet" href="~/css/item-wizard.css">
}
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
<script src="~/js/item-wizard.js?v=@DateTime.Now.Ticks"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
initTagInput('quoteTags', 'quoteTagsContainer');
var custEl = document.getElementById('customerSelect');
if (custEl) new TomSelect(custEl, { placeholder: '-- Select Customer --', openOnFocus: true, maxOptions: false,
onChange: function(value) { onQuoteCustomerChanged({ value: value }); }
});
// Show/hide SMS consent row based on phone field value
const phoneEl = document.getElementById('editProspectPhone');
if (phoneEl) {
phoneEl.addEventListener('input', updateEditProspectSmsConsent);
updateEditProspectSmsConsent();
}
});
function updateEditProspectSmsConsent() {
const phoneEl = document.getElementById('editProspectPhone');
const row = document.getElementById('editProspectSmsConsentRow');
if (phoneEl && row) row.style.display = phoneEl.value.trim() ? '' : 'none';
}
function onQuoteCustomerChanged(select) {
const meta = JSON.parse(document.getElementById('quoteMetaData').textContent);
const optOutIds = new Set(meta.emailOptOutCustomerIds || []);
const noEmailIds = new Set(meta.noEmailCustomerIds || []);
const smsConsentIds = new Set(meta.smsConsentCustomerIds || []);
const customerId = parseInt(select.value) || 0;
const noEmail = customerId > 0 && noEmailIds.has(customerId);
const emailSection = document.getElementById('emailNotifySection');
const smsNote = document.getElementById('smsNotifyNote');
if (emailSection) emailSection.style.display = noEmail ? 'none' : '';
if (!noEmail) {
const emailCheckbox = document.getElementById('SendEmailToCustomer');
const emailNote = document.getElementById('emailOptOutNote');
if (emailCheckbox) {
const optedOut = optOutIds.has(customerId);
emailCheckbox.disabled = optedOut;
if (optedOut) emailCheckbox.checked = false;
if (emailNote) emailNote.style.display = optedOut ? 'inline' : 'none';
}
}
if (smsNote) {
if (noEmail && customerId > 0) {
const hasSms = smsConsentIds.has(customerId);
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 &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';
}
}
}
// Discount type toggle
function onDiscountTypeChange() {
const type = document.getElementById('discountTypeSelect').value;
const show = type !== 'None';
document.getElementById('discountValueSection').style.display = show ? 'block' : 'none';
document.getElementById('discountReasonSection').style.display = show ? 'block' : 'none';
document.getElementById('hideDiscountSection').style.display = show ? 'block' : 'none';
}
// Surface area calculator
// Form submit guard
document.getElementById('quoteForm').addEventListener('submit', function(e) {
if (typeof quoteItems === 'undefined' || quoteItems.length === 0) {
e.preventDefault();
alert('Please add at least one item before submitting.');
}
});
// Quote photo direct upload (Edit page &mdash; quoteId is known)
(function () {
const quoteId = @Model.Id;
const uploadUrl = '@Url.Action("UploadQuotePhoto", "Quotes")';
const deleteUrl = '@Url.Action("DeleteQuotePhoto", "Quotes")';
const updateUrl = '@Url.Action("UpdateQuotePhoto", "Quotes")';
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
const fileInput = document.getElementById('editPhotoFileInput');
if (fileInput) {
fileInput.addEventListener('change', () => {
for (const file of fileInput.files) uploadPhoto(file);
fileInput.value = '';
});
}
async function uploadPhoto(file) {
const progress = document.getElementById('editPhotoUploadProgress');
progress?.classList.remove('d-none');
const fd = new FormData();
fd.append('quoteId', quoteId);
fd.append('file', file);
fd.append('__RequestVerificationToken', token);
const resp = await fetch(uploadUrl, { method: 'POST', body: fd });
const data = await resp.json();
progress?.classList.add('d-none');
if (!data.success) { alert(data.error || 'Upload failed.'); return; }
document.getElementById('noPhotosMsg')?.remove();
const grid = document.getElementById('editPhotoGrid');
const col = document.createElement('div');
col.className = 'col-sm-6 col-md-4 col-lg-3 photo-item';
col.id = 'photo-' + data.id;
col.innerHTML = `
<div class="card h-100 position-relative">
<button type="button" class="btn btn-sm btn-danger position-absolute top-0 end-0 m-1 p-0 px-1 delete-photo-btn"
style="z-index:1;font-size:.7rem;" data-photo-id="${data.id}" title="Delete">
<i class="bi bi-x"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary position-absolute top-0 start-0 m-1 p-0 px-1 edit-caption-btn"
style="z-index:1;font-size:.7rem;" data-photo-id="${data.id}" title="Edit caption">
<i class="bi bi-pencil"></i>
</button>
<a href="${data.url}" target="_blank" title="View full size">
<img src="${data.url}" class="card-img-top" style="height:160px;object-fit:cover;" loading="lazy">
</a>
<div class="card-body p-2">
<p class="card-text small text-muted text-truncate mb-0">${data.fileName}</p>
<p class="card-text text-muted mb-0" style="font-size:.7rem;">Just now</p>
<p class="card-text small fst-italic text-truncate mb-0 caption-display"></p>
<div class="caption-edit-form d-none mt-1" data-photo-id="${data.id}">
<textarea class="form-control form-control-sm caption-textarea" rows="2" placeholder="Add a caption..."></textarea>
<div class="d-flex gap-1 mt-1">
<button type="button" class="btn btn-primary btn-sm save-caption-btn flex-grow-1">Save</button>
<button type="button" class="btn btn-outline-secondary btn-sm cancel-caption-btn">Cancel</button>
</div>
</div>
</div>
</div>`;
grid.appendChild(col);
updateCount(1);
}
document.addEventListener('click', async (e) => {
const btn = e.target.closest('.delete-photo-btn');
if (!btn) return;
if (!confirm('Delete this photo?')) return;
const photoId = btn.dataset.photoId;
const fd = new FormData();
fd.append('id', photoId);
fd.append('__RequestVerificationToken', token);
const resp = await fetch(deleteUrl, { method: 'POST', body: fd });
const data = await resp.json();
if (!data.success) { alert(data.error || 'Delete failed.'); return; }
document.getElementById('photo-' + photoId)?.remove();
updateCount(-1);
});
// Show inline caption editor
document.addEventListener('click', (e) => {
const btn = e.target.closest('.edit-caption-btn');
if (!btn) return;
const card = btn.closest('.photo-item');
card.querySelector('.caption-display')?.classList.add('d-none');
card.querySelector('.caption-edit-form')?.classList.remove('d-none');
});
// Cancel inline caption edit
document.addEventListener('click', (e) => {
const btn = e.target.closest('.cancel-caption-btn');
if (!btn) return;
const card = btn.closest('.photo-item');
card.querySelector('.caption-edit-form')?.classList.add('d-none');
card.querySelector('.caption-display')?.classList.remove('d-none');
});
// Save caption
document.addEventListener('click', async (e) => {
const btn = e.target.closest('.save-caption-btn');
if (!btn) return;
const card = btn.closest('.photo-item');
const editForm = card.querySelector('.caption-edit-form');
const photoId = editForm?.dataset.photoId;
const caption = card.querySelector('.caption-textarea')?.value.trim() ?? '';
const fd = new FormData();
fd.append('id', photoId);
fd.append('caption', caption);
fd.append('__RequestVerificationToken', token);
const resp = await fetch(updateUrl, { method: 'POST', body: fd });
const data = await resp.json();
if (!data.success) { alert(data.error || 'Update failed.'); return; }
const displayEl = card.querySelector('.caption-display');
if (displayEl) { displayEl.textContent = caption; displayEl.title = caption; }
editForm?.classList.add('d-none');
card.querySelector('.caption-display')?.classList.remove('d-none');
});
function updateCount(delta) {
const badge = document.getElementById('photoCount');
if (badge) badge.textContent = parseInt(badge.textContent || '0') + delta;
}
})();
</script>
}