8d94013895
- New AI Quick Quote floating button: staff type a verbal description to get an instant price estimate for phone/walk-in customers; detected color names are fuzzy-matched against inventory for stock status; saves draft quote under a Walk-In / Phone customer with one click - Inline customer change on Quote Details and Job Details: always-visible native select with inline confirmation banner (no TomSelect dependency); ChangeCustomer AJAX endpoints on QuotesController and JobsController - Quote Edit page: customer dropdown is now editable (lock removed) - Fix AutoMapper missing CatalogCategory -> UpdateCategoryDto mapping that caused a crash on the catalog category Edit page - Help docs and AI knowledge base updated for all three features Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
781 lines
46 KiB
Plaintext
781 lines
46 KiB
Plaintext
@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" />
|
||
<span asp-validation-for="ProspectPhone" class="text-danger"></span>
|
||
</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 <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">
|
||
<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" 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 <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 -->
|
||
@{
|
||
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>
|
||
}
|
||
<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>
|
||
</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">
|
||
<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>
|
||
<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">
|
||
<i class="bi bi-check-circle me-1"></i>Update 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 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-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 — 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
|
||
})
|
||
})))
|
||
</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")"
|
||
}
|
||
</script>
|
||
|
||
@section Styles {
|
||
<style>
|
||
.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-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-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; }
|
||
.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-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="~/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 });
|
||
});
|
||
|
||
// 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 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 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>
|
||
<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>
|
||
</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);
|
||
});
|
||
|
||
function updateCount(delta) {
|
||
const badge = document.getElementById('photoCount');
|
||
if (badge) badge.textContent = parseInt(badge.textContent || '0') + delta;
|
||
}
|
||
})();
|
||
</script>
|
||
}
|