Files
PowderCoatingLogix/src/PowderCoating.Web/Views/Quotes/Edit.cshtml
T
spouliot 2bf8871892 Fix NoExtraLayerCharge persistence, appointment reminders, coat notes display, scroll restoration, and invoice Send dead-button
- Appointment reminders: add AppointmentReminderBackgroundService (60s poll), ReminderSentAt
  dedup stamp, NotifyAppointmentReminderAsync sends both customer email and creator staff email;
  AppointmentReminderStaff notification type + default template added; DateTime.Now used instead
  of UtcNow to match locally-stored ScheduledStartTime; ToLocalTime() double-conversion removed

- NoExtraLayerCharge not persisted: flag existed on CreateQuoteItemCoatDto and was used by
  pricing engine but never written to JobItemCoat/QuoteItemCoat entities — every edit reset it
  to false and re-applied the extra layer charge; added column to both entities (migration
  AddNoExtraLayerChargeToCoats), both read DTOs, all 3 JobItemAssemblyService overloads,
  JobItemCoatSeed inner class, and existingItemsData JSON in all 5 wizard views; fixed JS
  template path that hard-coded noExtraLayerCharge: false

- Coat notes not visible: notes were rendered in desktop job details but missing from the wizard
  item card summary and the mobile card view; both fixed

- Scroll position lost on item save: sessionStorage save/restore added to item-wizard.js owner
  form submit handler; path-keyed so cross-page navigation does not restore stale position;
  requestAnimationFrame used for reliable mobile scroll restoration

- Invoice Send dead button: #sendChannelModal was gated inside @if (isDraft) but the button
  targeting it fires for Sent/Overdue invoices too when customer has both email and SMS; modal
  moved outside the Draft guard

- InitialCreate migration added for fresh database installs; Baseline migration guarded with
  IF OBJECT_ID check so it no-ops on fresh DBs; Razor scoping bug fixed in Customers/Index.cshtml

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:48:16 -04:00

770 lines
45 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 — 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;">
<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 — 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; — 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;">
<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>
<!-- 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 — 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 — 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…</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 — send via SMS from quote details</text>
}
else
{
<i class="bi bi-phone-slash me-1"></i><text>No email — 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 — 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,
isAiItem = item.IsAiItem,
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,
vendorId = 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)),
"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 — send via SMS from quote details'
: '<i class="bi bi-phone-slash me-1"></i>No email — 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 — 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>
}