e2f9e9ae4f
- Standardize modal dismiss/cancel buttons to btn-outline-secondary across 70+ views - Remove btn-sm from page-level Create and Back buttons (Index + Detail pages) - Fix Edit buttons on Details pages: btn-secondary -> btn-warning - Fix form Cancel/Back links: btn-secondary -> btn-outline-secondary - Add 10 CSS patches to site.css for mobile/tablet responsiveness: top-navbar overflow prevention, page-header flex-wrap at 575px, table action button min-height override, notification dropdown width cap, tablet content padding Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
938 lines
55 KiB
Plaintext
938 lines
55 KiB
Plaintext
@model PowderCoating.Application.DTOs.Quote.CreateQuoteDto
|
||
@using PowderCoating.Core.Entities
|
||
|
||
@{
|
||
ViewData["Title"] = "New Quote";
|
||
ViewData["PageIcon"] = "bi-file-text-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="Index" class="btn btn-outline-secondary">
|
||
<i class="bi bi-arrow-left me-1"></i>Back to List
|
||
</a>
|
||
</div>
|
||
|
||
<form asp-action="Create" asp-controller="Quotes" method="post" id="quoteForm">
|
||
@Html.AntiForgeryToken()
|
||
<input type="hidden" name="guidedActivation" value="@ViewBag.GuidedActivation" />
|
||
<input type="hidden" asp-for="TaxPercent" />
|
||
|
||
@if ((ViewBag.GuidedActivation as string) == PowderCoating.Shared.Constants.AppConstants.GuidedActivation.QuoteFirstPath)
|
||
{
|
||
<div class="alert alert-primary alert-permanent border-0 shadow-sm mb-4">
|
||
<div class="fw-semibold mb-1">Step 1: Create your first sample quote</div>
|
||
<div>We've prefilled a quick example. You can edit anything before saving.</div>
|
||
</div>
|
||
}
|
||
|
||
<!-- Mode Toggle -->
|
||
<div class="d-flex align-items-center gap-2 mb-3">
|
||
<div class="quote-mode-toggle" role="group" aria-label="Quote mode">
|
||
<label class="quote-mode-opt">
|
||
<input type="radio" name="quoteMode" id="quoteModeQuick" value="simple" autocomplete="off">
|
||
<span><i class="bi bi-lightning-fill me-1"></i>Quick Quote</span>
|
||
</label>
|
||
<label class="quote-mode-opt">
|
||
<input type="radio" name="quoteMode" id="quoteModeFull" value="advanced" autocomplete="off">
|
||
<span><i class="bi bi-sliders me-1"></i>Full Quote</span>
|
||
</label>
|
||
</div>
|
||
<small class="text-muted fst-italic" id="quoteModeHint"></small>
|
||
</div>
|
||
|
||
<!-- 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
|
||
<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 <strong>Existing Customer</strong> if this person is already in your system. Choose <strong>New Prospect/Walk-In</strong> 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.<br><br><a href='/Help/Quotes#prospect-conversion' target='_blank'>Learn more →</a>">
|
||
<i class="bi bi-question-circle"></i>
|
||
</a>
|
||
</h5>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="mb-3">
|
||
<div class="form-check form-check-inline">
|
||
<input class="form-check-input" type="radio" name="IsForProspect" id="forCustomer"
|
||
value="false" @(!Model.IsForProspect ? "checked" : "") onchange="toggleCustomerProspect()">
|
||
<label class="form-check-label" for="forCustomer"><strong>Existing Customer</strong></label>
|
||
</div>
|
||
<div class="form-check form-check-inline">
|
||
<input class="form-check-input" type="radio" name="IsForProspect" id="forProspect"
|
||
value="true" @(Model.IsForProspect ? "checked" : "") onchange="toggleCustomerProspect()">
|
||
<label class="form-check-label" for="forProspect"><strong>New Prospect/Walk-In</strong></label>
|
||
</div>
|
||
</div>
|
||
<div id="customerSection" style="@(Model.IsForProspect ? "display:none" : null)">
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<label asp-for="CustomerId" class="form-label"></label>
|
||
<select asp-for="CustomerId" asp-items="ViewBag.Customers" id="customerSelect">
|
||
<option value="">-- Select Customer --</option>
|
||
</select>
|
||
<span asp-validation-for="CustomerId" class="text-danger"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="prospectSection" style="@(!Model.IsForProspect ? "display:none" : null)">
|
||
<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" id="prospectContactName" />
|
||
<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">Email <span class="text-muted small fw-normal">(required if no phone)</span></label>
|
||
<input asp-for="ProspectEmail" class="form-control" type="email" />
|
||
<span asp-validation-for="ProspectEmail" class="text-danger"></span>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<label asp-for="ProspectPhone" class="form-label">Phone <span class="text-muted small fw-normal">(required if no email)</span></label>
|
||
<input asp-for="ProspectPhone" class="form-control" type="tel" id="prospectPhone" />
|
||
<span asp-validation-for="ProspectPhone" class="text-danger"></span>
|
||
</div>
|
||
</div>
|
||
<div class="row mt-2" id="prospectSmsConsentRow" style="display:none;">
|
||
<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 quote-advanced-only">
|
||
<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>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Section 2: Quote Information -->
|
||
<div class="card mb-4 quote-advanced-only">
|
||
<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 <strong>Expiration Date</strong> is shown to the customer — once it passes the quote is flagged Expired and can no longer be approved without editing. The <strong>Customer PO</strong> field is optional — use it if the customer provides their own purchase order number.<br><br><a href='/Help/Quotes#quote-statuses' target='_blank'>Learn more →</a>">
|
||
<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 × to remove.</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Section 3: Oven & Batch Pricing -->
|
||
<div class="card mb-4 quote-advanced-only">
|
||
<div class="card-header">
|
||
<h5 class="mb-0"><i class="bi bi-fire me-2"></i>Oven & Batch Pricing
|
||
<a tabindex="0" class="help-icon" role="button"
|
||
data-bs-toggle="popover" data-bs-placement="right"
|
||
data-bs-title="Oven & 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="<strong>Calculated</strong> — you enter surface area (sq ft) and the system prices it using your rates for materials, labour, and overhead.<br><strong>Custom Work</strong> — you enter a description and a manual price. Use this for flat-rate jobs or work that doesn't fit the formula.<br><strong>AI Photo</strong> — upload photos and let the AI estimate surface area and complexity for you.<br><br><a href='/Help/Quotes#quote-items' target='_blank'>Learn more →</a>">
|
||
<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">
|
||
<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 quote-advanced-only">
|
||
<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 <strong>Percentage</strong> to reduce the total by a % (e.g., 10%), or <strong>Fixed Amount</strong> 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 <strong>Tier Discount</strong> appears automatically if the customer has a pricing tier assigned. A <strong>Rush Fee</strong> is added when Rush Job is checked.<br><br><a href='/Help/Quotes#pricing-breakdown' target='_blank'>Learn more →</a>">
|
||
<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 -->
|
||
@if (ViewBag.QuotePhotosEnabled is bool qpe && qpe)
|
||
{
|
||
<div class="card mb-4 quote-advanced-only" 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="stagedPhotoCount">0</span></h5>
|
||
<button type="button" class="btn btn-sm btn-outline-primary" onclick="document.getElementById('quotePhotoStagingInput').click()">
|
||
<i class="bi bi-cloud-upload me-1"></i>Add Photo
|
||
</button>
|
||
<input type="file" id="quotePhotoStagingInput" accept="image/*" class="d-none" multiple>
|
||
</div>
|
||
<div class="card-body">
|
||
<p class="text-muted small" id="stagedNoPhotosMsg">Photos will be attached to the quote when saved.</p>
|
||
<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>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
<!-- hidden fields for staged photo temp IDs / names are written by JS -->
|
||
|
||
<!-- Form Actions -->
|
||
<div class="card mb-4">
|
||
<div class="card-body d-flex align-items-center justify-content-end flex-wrap gap-3">
|
||
<div class="d-flex align-items-center gap-1" id="notifyCustomerSection">
|
||
<div id="emailNotifySection">
|
||
<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 quote via email
|
||
</label>
|
||
</div>
|
||
<span id="emailOptOutNote" class="badge bg-warning text-dark ms-1" style="display:none;"
|
||
title="This customer has email notifications turned off">
|
||
<i class="bi bi-bell-slash me-1"></i>Notifications off
|
||
</span>
|
||
</div>
|
||
<span id="smsNotifyNote" style="display:none;"></span>
|
||
<a tabindex="0" class="help-icon" role="button"
|
||
data-bs-toggle="popover" data-bs-placement="top"
|
||
data-bs-title="Send via Email"
|
||
data-bs-content="When checked, the customer is emailed a copy immediately after saving and the quote status is set to <strong>Sent</strong>. Leave unchecked to save as a <strong>Draft</strong> and send later from the Details page.">
|
||
<i class="bi bi-question-circle"></i>
|
||
</a>
|
||
</div>
|
||
<a asp-action="Index" asp-controller="Quotes" 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>Create Quote
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- Surface Area Calculator Modal -->
|
||
<div class="modal fade" id="sqFtCalculatorModal" tabindex="-1">
|
||
<div class="modal-dialog">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title"><i class="bi bi-calculator me-2"></i>Surface Area Calculator <small class="text-muted">(per item)</small></h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="mb-3">
|
||
<label class="form-label">Shape</label>
|
||
<select id="calcShape" class="form-select" onchange="toggleShapeInputs()">
|
||
<option value="rectangle">Rectangle / Square</option>
|
||
<option value="cylinder">Cylinder (Tube)</option>
|
||
<option value="circle">Circle (Flat)</option>
|
||
</select>
|
||
</div>
|
||
<div id="rectangleInputs">
|
||
<div class="row g-2">
|
||
<div class="col-6"><label class="form-label">Length (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
||
<input type="number" id="rectLength" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
||
<div class="col-6"><label class="form-label">Width (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
||
<input type="number" id="rectWidth" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
||
</div>
|
||
<small class="text-muted">Formula: L × W ÷ @(ViewBag.UseMetric == true ? "10,000" : "144")</small>
|
||
</div>
|
||
<div id="cylinderInputs" style="display:none">
|
||
<div class="row g-2">
|
||
<div class="col-6"><label class="form-label">Diameter (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
||
<input type="number" id="cylDiameter" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
||
<div class="col-6"><label class="form-label">Height (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
||
<input type="number" id="cylHeight" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
||
</div>
|
||
</div>
|
||
<div id="circleInputs" style="display:none">
|
||
<label class="form-label">Diameter (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
||
<input type="number" id="circDiameter" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()">
|
||
</div>
|
||
<hr />
|
||
<div class="alert alert-info alert-permanent mb-0"><strong>Result:</strong> <span id="calcResult">0.00</span> @ViewBag.AreaUnit</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||
<button type="button" class="btn btn-primary" onclick="useSqFtResult()">
|
||
<i class="bi bi-check-circle me-1"></i>Use This Value
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ========================= ITEM WIZARD MODAL ========================= -->
|
||
<div class="modal fade" id="itemWizardModal" tabindex="-1" data-bs-backdrop="static">
|
||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<div class="d-flex flex-column">
|
||
<h5 class="modal-title mb-0" id="wizardTitle">Add Item</h5>
|
||
<div class="text-muted small mb-1" id="wizardStepTitle">Choose Item Type</div>
|
||
<!-- Step progress -->
|
||
<div class="d-flex align-items-center gap-2" id="wizardStepIndicator">
|
||
<span class="wizard-step-dot active" data-step="1" title="Item Type"></span>
|
||
<div class="wizard-step-line"></div>
|
||
<span class="wizard-step-dot" data-step="2" title="Item Details"></span>
|
||
<div class="wizard-step-line" id="step2Line"></div>
|
||
<span class="wizard-step-dot" data-step="3" title="Coating Layers" id="step3Dot"></span>
|
||
<div class="wizard-step-line" id="step3Line"></div>
|
||
<span class="wizard-step-dot" data-step="4" title="Prep Services" id="step4Dot"></span>
|
||
<span class="text-muted small ms-2" id="wizardStepLabel">Step 1 of 4</span>
|
||
</div>
|
||
</div>
|
||
<button type="button" class="btn-close ms-auto" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body" id="wizardBody" style="min-height: 300px;">
|
||
<!-- Content injected by JS -->
|
||
</div>
|
||
<div class="modal-footer justify-content-between">
|
||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||
<div class="d-flex gap-2">
|
||
<button type="button" class="btn btn-outline-secondary d-none" id="btnWizardBack" onclick="wizardBack()">
|
||
<i class="bi bi-arrow-left me-1"></i>Back
|
||
</button>
|
||
<button type="button" class="btn btn-primary" id="btnWizardNext" onclick="wizardNext()">
|
||
Next <i class="bi bi-arrow-right ms-1"></i>
|
||
</button>
|
||
<button type="button" class="btn btn-success d-none" id="btnWizardSave" onclick="wizardSave()">
|
||
<i class="bi bi-check-lg me-1"></i>Add Item
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 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 (for validation re-render) -->
|
||
@if (Model.QuoteItems != null && Model.QuoteItems.Any())
|
||
{
|
||
<script id="existingItemsData" type="application/json">
|
||
@Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.QuoteItems.Select((item, i) => new {
|
||
description = item.Description,
|
||
quantity = item.Quantity,
|
||
surfaceAreaSqFt = item.SurfaceAreaSqFt,
|
||
estimatedMinutes = item.EstimatedMinutes,
|
||
catalogItemId = item.CatalogItemId,
|
||
manualUnitPrice = item.ManualUnitPrice,
|
||
isGenericItem = item.IsGenericItem,
|
||
isLaborItem = item.IsLaborItem,
|
||
requiresSandblasting = item.RequiresSandblasting,
|
||
requiresMasking = item.RequiresMasking,
|
||
notes = item.Notes,
|
||
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
|
||
})
|
||
})))
|
||
</script>
|
||
}
|
||
|
||
<script id="quoteMetaData" type="application/json">
|
||
{
|
||
"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>())),
|
||
"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>())),
|
||
"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")"
|
||
}
|
||
</script>
|
||
|
||
@section Styles {
|
||
<link rel="stylesheet" href="~/lib/tom-select/css/tom-select.bootstrap5.min.css">
|
||
<style>
|
||
/* Quick / Full quote mode toggle */
|
||
#quoteForm.quote-simple-mode .quote-advanced-only { display: none !important; }
|
||
.quote-mode-toggle {
|
||
display: inline-flex;
|
||
background: var(--bs-tertiary-bg);
|
||
border: 1px solid var(--bs-border-color);
|
||
border-radius: 999px;
|
||
padding: 3px;
|
||
gap: 2px;
|
||
}
|
||
.quote-mode-opt { margin: 0; }
|
||
.quote-mode-opt input { display: none; }
|
||
.quote-mode-opt span {
|
||
display: block;
|
||
padding: 4px 16px;
|
||
border-radius: 999px;
|
||
cursor: pointer;
|
||
font-size: .85rem;
|
||
font-weight: 600;
|
||
color: var(--bs-secondary-color);
|
||
transition: background .15s, color .15s, box-shadow .15s;
|
||
user-select: none;
|
||
}
|
||
.quote-mode-opt input:checked + span {
|
||
background: #0d6efd;
|
||
color: #fff;
|
||
box-shadow: 0 1px 4px rgba(13,110,253,.35);
|
||
}
|
||
.quote-mode-opt span:hover { color: var(--bs-body-color); }
|
||
.quote-mode-opt input:checked + span:hover { color: #fff; }
|
||
/* Wizard step indicator */
|
||
.wizard-step-dot {
|
||
width: 22px; height: 22px; border-radius: 50%;
|
||
background: #dee2e6; display: inline-block; cursor: default;
|
||
border: 2px solid #dee2e6; transition: all .2s;
|
||
flex-shrink: 0;
|
||
}
|
||
.wizard-step-dot.active { background: #0d6efd; border-color: #0d6efd; }
|
||
.wizard-step-dot.done { background: #198754; border-color: #198754; }
|
||
.wizard-step-dot.skip { background: #adb5bd; border-color: #adb5bd; }
|
||
.wizard-step-line { flex: 1; height: 2px; background: #dee2e6; min-width: 30px; }
|
||
/* Item type picker cards */
|
||
.item-type-card {
|
||
border: 2px solid #dee2e6; border-radius: .75rem; padding: 1.25rem 1rem;
|
||
cursor: pointer; transition: all .15s; text-align: center;
|
||
background: #fff; user-select: none;
|
||
}
|
||
.item-type-card:hover { border-color: #86b7fe; background: #f0f6ff; }
|
||
.item-type-card.selected { border-color: #0d6efd; background: #eef3ff; }
|
||
.item-type-card .item-type-icon { font-size: 2rem; margin-bottom: .5rem; }
|
||
[data-bs-theme="dark"] .item-type-card { background: var(--bs-tertiary-bg); border-color: var(--bs-border-color); color: var(--bs-body-color); }
|
||
[data-bs-theme="dark"] .item-type-card:hover { border-color: #86b7fe; background: var(--bs-secondary-bg); }
|
||
[data-bs-theme="dark"] .item-type-card.selected { border-color: #0d6efd; background: #1a2a4a; }
|
||
/* Catalog listbox (replaces native <select> for cross-platform filter support) */
|
||
.catalog-list-item { cursor: pointer; border-bottom: 1px solid var(--bs-border-color); font-size: .9rem; transition: background .1s; }
|
||
.catalog-list-item:last-child { border-bottom: none; }
|
||
.catalog-list-item:hover { background: var(--bs-tertiary-bg); }
|
||
.catalog-list-item.selected { background: #eef3ff; color: #0d6efd; font-weight: 600; }
|
||
[data-bs-theme="dark"] .catalog-list-item.selected { background: #1a2a4a; color: #86b7fe; }
|
||
/* Summary cards */
|
||
.quote-item-card {
|
||
border: 1px solid #dee2e6; border-radius: .5rem;
|
||
padding: .75rem 1rem; margin-bottom: .5rem;
|
||
background: #fafafa;
|
||
}
|
||
.quote-item-card .item-badge { font-size: .7rem; }
|
||
/* Coat rows in wizard */
|
||
.coat-row { border: 1px solid #dee2e6; border-radius: .5rem; padding: .75rem; margin-bottom: .5rem; }
|
||
[data-bs-theme="dark"] .quote-item-card { background: var(--bs-tertiary-bg); border-color: var(--bs-border-color); color: var(--bs-body-color); }
|
||
[data-bs-theme="dark"] .coat-row { background: var(--bs-tertiary-bg); border-color: var(--bs-border-color); }
|
||
</style>
|
||
}
|
||
|
||
@section Scripts {
|
||
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
|
||
<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 ──────────────────────────────────
|
||
(function () {
|
||
const STORAGE_KEY = 'pcl_quote_mode';
|
||
const form = document.getElementById('quoteForm');
|
||
const hint = document.getElementById('quoteModeHint');
|
||
|
||
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.';
|
||
} else {
|
||
form.classList.remove('quote-simple-mode');
|
||
hint.textContent = '';
|
||
}
|
||
}
|
||
|
||
const saved = localStorage.getItem(STORAGE_KEY) || 'advanced';
|
||
document.getElementById(saved === 'simple' ? 'quoteModeQuick' : 'quoteModeFull').checked = true;
|
||
applyMode(saved);
|
||
|
||
document.querySelectorAll('input[name="quoteMode"]').forEach(function (radio) {
|
||
radio.addEventListener('change', function () {
|
||
localStorage.setItem(STORAGE_KEY, this.value);
|
||
applyMode(this.value);
|
||
});
|
||
});
|
||
})();
|
||
|
||
document.addEventListener('DOMContentLoaded', function () {
|
||
initTagInput('quoteTags', 'quoteTagsContainer');
|
||
new TomSelect('#customerSelect', {
|
||
placeholder: '-- Select Customer --',
|
||
openOnFocus: true,
|
||
maxOptions: false,
|
||
onChange: function(value) {
|
||
onQuoteCustomerChanged({ value: value });
|
||
}
|
||
});
|
||
});
|
||
|
||
// Update tax rate, email visibility, and SMS note when customer changes
|
||
function onQuoteCustomerChanged(select) {
|
||
const meta = JSON.parse(document.getElementById('quoteMetaData').textContent);
|
||
const exemptIds = new Set(meta.taxExemptCustomerIds || []);
|
||
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 taxField = document.querySelector('[name="TaxPercent"]');
|
||
if (taxField) {
|
||
taxField.value = exemptIds.has(customerId) ? 0 : (meta.companyTaxPercent ?? meta.taxPercent);
|
||
}
|
||
|
||
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';
|
||
}
|
||
}
|
||
}
|
||
|
||
// Toggle customer / prospect sections
|
||
function toggleCustomerProspect() {
|
||
const isProspect = document.getElementById('forProspect').checked;
|
||
document.getElementById('customerSection').style.display = isProspect ? 'none' : 'block';
|
||
document.getElementById('prospectSection').style.display = isProspect ? 'block' : 'none';
|
||
if (!isProspect) {
|
||
['prospectContactName','prospectPhone'].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.removeAttribute('required');
|
||
});
|
||
}
|
||
updateProspectSmsConsentVisibility();
|
||
}
|
||
|
||
function updateProspectSmsConsentVisibility() {
|
||
const phoneEl = document.getElementById('prospectPhone');
|
||
const row = document.getElementById('prospectSmsConsentRow');
|
||
if (!phoneEl || !row) return;
|
||
const isProspect = document.getElementById('forProspect')?.checked;
|
||
row.style.display = (isProspect && phoneEl.value.trim()) ? '' : 'none';
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', function () {
|
||
const phoneEl = document.getElementById('prospectPhone');
|
||
if (phoneEl) phoneEl.addEventListener('input', updateProspectSmsConsentVisibility);
|
||
updateProspectSmsConsentVisibility();
|
||
});
|
||
|
||
// 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
|
||
let _sqFtTargetInput = null;
|
||
function openSqFtCalculator(inputId) {
|
||
_sqFtTargetInput = inputId;
|
||
document.getElementById('rectLength').value = 0;
|
||
document.getElementById('rectWidth').value = 0;
|
||
document.getElementById('calcResult').textContent = '0.00';
|
||
new bootstrap.Modal(document.getElementById('sqFtCalculatorModal')).show();
|
||
}
|
||
function toggleShapeInputs() {
|
||
const shape = document.getElementById('calcShape').value;
|
||
document.getElementById('rectangleInputs').style.display = shape === 'rectangle' ? 'block' : 'none';
|
||
document.getElementById('cylinderInputs').style.display = shape === 'cylinder' ? 'block' : 'none';
|
||
document.getElementById('circleInputs').style.display = shape === 'circle' ? 'block' : 'none';
|
||
calculateSqFt();
|
||
}
|
||
function calculateSqFt() {
|
||
const useMetric = @Json.Serialize((bool)(ViewBag.UseMetric ?? false));
|
||
const divisor = useMetric ? 10000 : 144;
|
||
const shape = document.getElementById('calcShape').value;
|
||
let result = 0;
|
||
if (shape === 'rectangle') {
|
||
const l = parseFloat(document.getElementById('rectLength').value) || 0;
|
||
const w = parseFloat(document.getElementById('rectWidth').value) || 0;
|
||
result = (l * w) / divisor;
|
||
} else if (shape === 'cylinder') {
|
||
const d = parseFloat(document.getElementById('cylDiameter').value) || 0;
|
||
const h = parseFloat(document.getElementById('cylHeight').value) || 0;
|
||
const r = d / 2;
|
||
result = (2 * Math.PI * r * r + 2 * Math.PI * r * h) / divisor;
|
||
} else {
|
||
const d = parseFloat(document.getElementById('circDiameter').value) || 0;
|
||
const r = d / 2;
|
||
result = (Math.PI * r * r) / divisor;
|
||
}
|
||
document.getElementById('calcResult').textContent = result.toFixed(4);
|
||
}
|
||
function useSqFtResult() {
|
||
const val = document.getElementById('calcResult').textContent;
|
||
if (_sqFtTargetInput) {
|
||
const el = document.getElementById(_sqFtTargetInput) || document.querySelector(`[name="${_sqFtTargetInput}"]`);
|
||
if (el) { el.value = parseFloat(val).toFixed(2); el.dispatchEvent(new Event('change')); }
|
||
}
|
||
bootstrap.Modal.getInstance(document.getElementById('sqFtCalculatorModal'))?.hide();
|
||
}
|
||
|
||
// 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 staging (temp upload before quoteId exists)
|
||
(function () {
|
||
const uploadUrl = '@Url.Action("UploadQuotePhotoTemp", "Quotes")';
|
||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||
const stagedPhotos = []; // {tempId, fileName}
|
||
|
||
const fileInput = document.getElementById('quotePhotoStagingInput');
|
||
if (fileInput) {
|
||
fileInput.addEventListener('change', () => {
|
||
for (const file of fileInput.files) stagePhoto(file);
|
||
fileInput.value = '';
|
||
});
|
||
}
|
||
|
||
async function stagePhoto(file) {
|
||
const progress = document.getElementById('stagedPhotoUploadProgress');
|
||
progress?.classList.remove('d-none');
|
||
|
||
const fd = new FormData();
|
||
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; }
|
||
|
||
stagedPhotos.push({ tempId: data.tempId, fileName: data.fileName });
|
||
document.getElementById('stagedNoPhotosMsg')?.remove();
|
||
|
||
const grid = document.getElementById('stagedPhotoGrid');
|
||
const idx = stagedPhotos.length - 1;
|
||
const col = document.createElement('div');
|
||
col.className = 'col-sm-6 col-md-4 col-lg-3';
|
||
col.id = 'staged-' + idx;
|
||
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"
|
||
style="z-index:1;font-size:.7rem;" onclick="removeStagedPhoto(${idx})" title="Remove">
|
||
<i class="bi bi-x"></i>
|
||
</button>
|
||
<div class="d-flex align-items-center justify-content-center bg-light" style="height:160px;">
|
||
<i class="bi bi-image text-muted" style="font-size:2rem;"></i>
|
||
</div>
|
||
<div class="card-body p-2">
|
||
<p class="card-text small text-muted text-truncate mb-0" title="${data.fileName}">${data.fileName}</p>
|
||
<p class="card-text text-muted mb-0" style="font-size:.7rem;">${data.fileSizeDisplay}</p>
|
||
</div>
|
||
</div>`;
|
||
grid.appendChild(col);
|
||
|
||
const badge = document.getElementById('stagedPhotoCount');
|
||
if (badge) badge.textContent = stagedPhotos.filter(p => p).length;
|
||
|
||
rebuildHiddenFields();
|
||
}
|
||
|
||
window.removeStagedPhoto = function (idx) {
|
||
stagedPhotos[idx] = null;
|
||
document.getElementById('staged-' + idx)?.remove();
|
||
const badge = document.getElementById('stagedPhotoCount');
|
||
if (badge) badge.textContent = stagedPhotos.filter(p => p).length;
|
||
rebuildHiddenFields();
|
||
};
|
||
|
||
function rebuildHiddenFields() {
|
||
const container = document.getElementById('quotePhotoTempIdsContainer');
|
||
if (!container) return;
|
||
const valid = stagedPhotos.filter(p => p);
|
||
container.innerHTML = valid.map((p, i) =>
|
||
`<input type="hidden" name="QuotePhotoTempIds[${i}]" value="${p.tempId}">` +
|
||
`<input type="hidden" name="QuotePhotoFileNames[${i}]" value="${p.fileName}">`
|
||
).join('');
|
||
}
|
||
})();
|
||
</script>
|
||
}
|