Restore all zeroed views + add bulk gift certificate creation
The HTML entity sweep script had a bug where it wrote empty files for any
view that contained no target Unicode characters, zeroing out 215 view files.
All views restored from the pre-sweep commit (cefdf3e).
Bulk gift certificate feature:
- BulkCreateGiftCertificateDto with Quantity (1-500), Amount, Reason, Expiry, Notes
- GenerateBulkGiftCertificatePdfAsync on IPdfService / PdfService: one Letter page
per cert, reusing the same purple/gold branded ComposeGiftCertificateContent helper
- GiftCertificatesController: BulkCreate GET/POST, BulkResult GET, BulkDownloadPdf POST
- Views: BulkCreate.cshtml (form with live total preview), BulkResult.cshtml (table +
Download All PDF button that POSTs cert IDs to avoid URL length limits)
- gift-certificate-bulk.js: live preview + spinner/disable on submit
- Index.cshtml: Bulk Create button added alongside New Certificate
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,761 @@
|
||||
@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>
|
||||
|
||||
@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 (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; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="~/css/item-wizard.css">
|
||||
}
|
||||
|
||||
@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';
|
||||
}
|
||||
|
||||
// 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>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
@model PowderCoating.Application.DTOs.Quote.QuoteDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Delete Quote";
|
||||
ViewData["PageIcon"] = "bi-trash";
|
||||
var linkedJobId = ViewBag.LinkedJobId as int?;
|
||||
var linkedJobNumber = ViewBag.LinkedJobNumber as string;
|
||||
bool isLocked = linkedJobId.HasValue;
|
||||
}
|
||||
|
||||
<div class="container-fluid mt-4">
|
||||
<div class="mb-4">
|
||||
<p class="text-muted">@(isLocked ? "This quote cannot be deleted." : "Are you sure you want to delete this quote?")</p>
|
||||
</div>
|
||||
|
||||
@if (isLocked)
|
||||
{
|
||||
<div class="alert alert-warning alert-permanent">
|
||||
<h5 class="alert-heading">
|
||||
<i class="bi bi-lock-fill me-2"></i>Quote Is Locked — Linked Job Exists
|
||||
</h5>
|
||||
<p class="mb-1">
|
||||
Quote <strong>@Model.QuoteNumber</strong> was used to create
|
||||
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@linkedJobId" class="fw-semibold alert-link">@linkedJobNumber</a>.
|
||||
It cannot be deleted while that job exists — the quote is the original pricing and approval record for the job.
|
||||
</p>
|
||||
<p class="mb-0">To delete this quote, delete <strong>@linkedJobNumber</strong> first.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-danger alert-permanent">
|
||||
<h5 class="alert-heading">
|
||||
<i class="bi bi-exclamation-circle me-2"></i>Warning
|
||||
</h5>
|
||||
<p class="mb-0">
|
||||
This action will permanently delete quote <strong>@Model.QuoteNumber</strong> and all its associated items.
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<!-- Quote Summary -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-file-text me-2"></i>Quote Information
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<p><strong>Quote Number:</strong> @Model.QuoteNumber</p>
|
||||
<p><strong>Status:</strong>
|
||||
<span class="badge bg-@Model.StatusColorClass">@Model.StatusDisplayName</span>
|
||||
</p>
|
||||
<p><strong>Quote Date:</strong> @Model.QuoteDate.ToString("MM/dd/yyyy")</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<p><strong>Customer/Prospect/Walk-In:</strong> @Model.CustomerOrProspectName</p>
|
||||
<p><strong>Total Amount:</strong> <strong class="text-primary">@Model.Total.ToString("C")</strong></p>
|
||||
@if (Model.ExpirationDate.HasValue)
|
||||
{
|
||||
<p><strong>Expiration Date:</strong> @Model.ExpirationDate.Value.ToString("MM/dd/yyyy")</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(Model.Description))
|
||||
{
|
||||
<hr />
|
||||
<p><strong>Description:</strong> @Model.Description</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quote Items Summary -->
|
||||
@if (Model.QuoteItems != null && Model.QuoteItems.Any())
|
||||
{
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-list-ul me-2"></i>Quote Items (@Model.QuoteItems.Count)
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th>Quantity</th>
|
||||
<th>Color</th>
|
||||
<th>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var item in Model.QuoteItems)
|
||||
{
|
||||
<tr>
|
||||
<td>@item.Description</td>
|
||||
<td>@item.Quantity</td>
|
||||
<td>@(item.Coats.FirstOrDefault()?.ColorName ?? "-")</td>
|
||||
<td>@item.TotalPrice.ToString("C")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Delete Form / Locked Actions -->
|
||||
<div class="mb-4">
|
||||
@if (isLocked)
|
||||
{
|
||||
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@linkedJobId" class="btn btn-outline-primary btn-lg me-2">
|
||||
<i class="bi bi-briefcase me-1"></i>Go to @linkedJobNumber
|
||||
</a>
|
||||
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-outline-secondary btn-lg">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to Quote
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<form asp-action="Delete" method="post">
|
||||
<input type="hidden" asp-for="Id" />
|
||||
<button type="submit" class="btn btn-danger btn-lg">
|
||||
<i class="bi bi-trash me-1"></i>Yes, Delete This Quote
|
||||
</button>
|
||||
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-outline-secondary btn-lg">
|
||||
<i class="bi bi-x-circle me-1"></i>Cancel
|
||||
</a>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Additional Info -->
|
||||
<div class="col-lg-4">
|
||||
<div class="card border-danger">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-info-circle me-2"></i>What Will Be Deleted?
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="mb-0">
|
||||
<li>The quote record (@Model.QuoteNumber)</li>
|
||||
<li>All @Model.QuoteItems.Count quote item(s)</li>
|
||||
<li>All associated pricing information</li>
|
||||
</ul>
|
||||
<hr />
|
||||
<p class="text-muted mb-0">
|
||||
<small>
|
||||
<strong>Note:</strong> If this quote is linked to a customer, the customer record will
|
||||
remain intact. Only the quote and its items will be deleted.
|
||||
</small>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (Model.StatusCode == "CONVERTED")
|
||||
{
|
||||
<div class="card border-warning mt-3">
|
||||
<div class="card-header bg-warning">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>Additional Warning
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-0">
|
||||
This quote has been converted to a customer. Deleting it will not affect the
|
||||
customer record that was created from this quote.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,768 @@
|
||||
@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 <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>
|
||||
<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
|
||||
})
|
||||
})))
|
||||
</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>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
@model PagedResult<PowderCoating.Application.DTOs.Quote.QuoteListDto>
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Quotes";
|
||||
ViewData["PageIcon"] = "bi-file-text";
|
||||
var searchTerm = ViewBag.SearchTerm as string;
|
||||
var statusFilter = ViewBag.StatusFilter as int?;
|
||||
var statusCode = ViewBag.StatusCode as string;
|
||||
}
|
||||
|
||||
@{
|
||||
var _statOpen = (int)(ViewBag.StatOpenCount ?? 0);
|
||||
var _statApproved = (int)(ViewBag.StatApprovedCount ?? 0);
|
||||
var _statValue = (decimal)(ViewBag.StatTotalValue ?? 0m);
|
||||
var _notCalibrated = (bool)(ViewBag.QuotingNotCalibrated ?? false);
|
||||
}
|
||||
|
||||
@if (_notCalibrated)
|
||||
{
|
||||
<div class="alert alert-warning alert-permanent alert-dismissible d-flex align-items-start gap-3 mb-3" id="quotingCalibrationNudge">
|
||||
<i class="bi bi-speedometer2 fs-5 flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
<strong>Your quoting isn't calibrated yet.</strong>
|
||||
AI photo quotes and calculated item time estimates are using generic industry averages — they may not match your shop's actual throughput.
|
||||
<a href="/CompanySettings#quoting-calibration" class="alert-link ms-1">Set up your equipment profile →</a>
|
||||
</div>
|
||||
<button type="button" class="btn-close ms-auto flex-shrink-0" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="pcl-metric-strip">
|
||||
<div class="pcl-metric-strip-cell">
|
||||
@await Html.PartialAsync("_Metric", (Label: "TOTAL", Value: Model.TotalCount.ToString(), Delta: (string?)null, DeltaDir: (string?)null))
|
||||
</div>
|
||||
<div class="pcl-metric-strip-cell">
|
||||
@await Html.PartialAsync("_Metric", (Label: "OPEN", Value: _statOpen.ToString(), Delta: (string?)null, DeltaDir: (string?)null))
|
||||
</div>
|
||||
<div class="pcl-metric-strip-cell">
|
||||
@await Html.PartialAsync("_Metric", (Label: "APPROVED", Value: _statApproved.ToString(), Delta: (string?)null, DeltaDir: (string?)null))
|
||||
</div>
|
||||
<div class="pcl-metric-strip-cell">
|
||||
@await Html.PartialAsync("_Metric", (Label: "TOTAL VALUE", Value: _statValue.ToString("C0"), Delta: (string?)null, DeltaDir: (string?)null))
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quote List Card -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header border-0 py-3">
|
||||
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-start align-items-lg-center gap-3">
|
||||
<div class="d-flex flex-column flex-sm-row gap-2 w-100 w-lg-auto">
|
||||
<form asp-action="Index" asp-controller="Quotes" method="get" class="d-flex flex-column flex-sm-row gap-2 flex-grow-1 flex-lg-grow-0">
|
||||
<input type="hidden" name="sortColumn" value="@ViewBag.SortColumn" />
|
||||
<input type="hidden" name="sortDirection" value="@ViewBag.SortDirection" />
|
||||
<input type="hidden" name="pageSize" value="@Model.PageSize" />
|
||||
<div class="input-group" style="max-width: 280px; min-width: 200px;">
|
||||
<span class="input-group-text border-end-0">
|
||||
<i class="bi bi-search text-muted"></i>
|
||||
</span>
|
||||
<input type="text" name="searchTerm" class="form-control border-start-0"
|
||||
placeholder="Search quotes..." value="@searchTerm">
|
||||
</div>
|
||||
<select class="form-select" name="statusFilter" style="width: auto;">
|
||||
<option value="">All Statuses</option>
|
||||
@foreach (var status in ViewBag.QuoteStatuses as IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)
|
||||
{
|
||||
<option value="@status.Value" selected="@status.Selected">@status.Text</option>
|
||||
}
|
||||
</select>
|
||||
<input type="text" name="tagFilter" value="@ViewBag.TagFilter" class="form-control" style="min-width: 140px; max-width: 180px;" placeholder="Filter by tag..." />
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-search"></i></button>
|
||||
@if (!string.IsNullOrEmpty(searchTerm) || statusFilter.HasValue || !string.IsNullOrEmpty(statusCode) || !string.IsNullOrEmpty(ViewBag.TagFilter as string))
|
||||
{
|
||||
<a asp-action="Index" asp-controller="Quotes" class="btn btn-outline-secondary">Clear</a>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(statusCode) && statusCode.ToUpper() == "SENT")
|
||||
{
|
||||
<span class="badge bg-warning text-dark fs-6 fw-normal">
|
||||
<i class="bi bi-funnel-fill me-1"></i>Pending (Sent) Quotes
|
||||
</span>
|
||||
}
|
||||
</form>
|
||||
<a asp-action="Create" asp-controller="Quotes" class="btn btn-primary text-nowrap">
|
||||
<i class="bi bi-plus-circle me-2"></i>
|
||||
<span class="d-none d-sm-inline">New Quote</span>
|
||||
<span class="d-inline d-sm-none">New</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
@if (Model != null && Model.Items.Any())
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th sortable="QuoteNumber" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Quote Number</th>
|
||||
<th>Customer/Prospect</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
<th sortable="Status" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Status</th>
|
||||
<th sortable="QuoteDate" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Quote Date</th>
|
||||
<th sortable="ExpirationDate" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Expiration Date</th>
|
||||
<th sortable="Total" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Total</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var quote in Model.Items)
|
||||
{
|
||||
<tr class="quote-row @(quote.IsExpired ? "table-warning" : "")" data-quote-id="@quote.Id" style="cursor: pointer;">
|
||||
<td>
|
||||
<strong>@quote.QuoteNumber</strong>
|
||||
@if (!string.IsNullOrWhiteSpace(quote.Tags))
|
||||
{
|
||||
<div class="mt-1">
|
||||
@foreach (var tag in quote.Tags.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(t => t.Trim()).Where(t => !string.IsNullOrWhiteSpace(t)))
|
||||
{
|
||||
<a href="@Url.Action("Index", new { tagFilter = tag, statusFilter = ViewBag.StatusFilter })" class="badge rounded-pill bg-info text-dark tag-index-badge me-1 text-decoration-none">@tag</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td>@quote.CustomerOrProspectName</td>
|
||||
<td>
|
||||
@await Html.PartialAsync("_StatusChip", (Kind: quote.IsProspect ? "cool" : "ok", Text: quote.IsProspect ? "Prospect" : "Customer"))
|
||||
</td>
|
||||
<td>
|
||||
@if (!string.IsNullOrWhiteSpace(quote.Description))
|
||||
{
|
||||
<span class="text-muted" title="@quote.Description">
|
||||
@(quote.Description.Length > 50 ? quote.Description.Substring(0, 50) + "..." : quote.Description)
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">-</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span class="pcl-chip pcl-chip-@StatusChipHelper.QuoteStatus(quote.StatusCode) status-badge"
|
||||
style="cursor:pointer;"
|
||||
data-quote-id="@quote.Id"
|
||||
data-quote-number="@quote.QuoteNumber"
|
||||
data-status-id="@quote.QuoteStatusId"
|
||||
data-status-name="@quote.StatusDisplayName"
|
||||
title="Click to change status">
|
||||
<span class="pcl-chip-dot"></span>@quote.StatusDisplayName
|
||||
</span>
|
||||
@if (quote.IsExpired)
|
||||
{
|
||||
<i class="bi bi-exclamation-triangle text-warning ms-1" title="Quote has expired"></i>
|
||||
}
|
||||
</td>
|
||||
<td>@quote.QuoteDate.ToString("MM/dd/yyyy")</td>
|
||||
<td>
|
||||
@if (quote.ExpirationDate.HasValue)
|
||||
{
|
||||
@quote.ExpirationDate.Value.ToString("MM/dd/yyyy")
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">-</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<strong>@quote.Total.ToString("C")</strong>
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group" role="group">
|
||||
<a asp-action="Details" asp-controller="Quotes" asp-route-id="@quote.Id"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
title="View Details">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<a asp-action="Edit" asp-controller="Quotes" asp-route-id="@quote.Id"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
title="Edit">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
@if (quote.IsProspect && quote.StatusCode == "APPROVED")
|
||||
{
|
||||
<a asp-action="ConvertToCustomer" asp-controller="Quotes" asp-route-id="@quote.Id"
|
||||
class="btn btn-sm btn-outline-success"
|
||||
title="Convert to Customer">
|
||||
<i class="bi bi-arrow-right-circle"></i>
|
||||
</a>
|
||||
}
|
||||
<a asp-action="Delete" asp-controller="Quotes" asp-route-id="@quote.Id"
|
||||
class="btn btn-sm btn-outline-danger"
|
||||
title="Delete">
|
||||
<i class="bi bi-trash"></i>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Card View -->
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var quote in Model.Items)
|
||||
{
|
||||
<div class="mobile-data-card @(quote.IsExpired ? "border-warning" : "")"
|
||||
data-id="@quote.Id"
|
||||
onclick="window.location.href='@Url.Action("Details", "Quotes", new { id = quote.Id })'">
|
||||
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
|
||||
<i class="bi bi-file-text"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@quote.QuoteNumber</h6>
|
||||
<small>@quote.CustomerOrProspectName</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Type</span>
|
||||
<span class="mobile-card-value">
|
||||
@if (quote.IsProspect)
|
||||
{
|
||||
<span class="badge bg-info">
|
||||
<i class="bi bi-person me-1"></i>Prospect
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-success">
|
||||
<i class="bi bi-building me-1"></i>Customer
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value">
|
||||
@await Html.PartialAsync("_StatusChip", (Kind: StatusChipHelper.QuoteStatus(quote.StatusCode), Text: quote.StatusDisplayName))
|
||||
@if (quote.IsExpired)
|
||||
{
|
||||
<i class="bi bi-exclamation-triangle text-warning ms-1" title="Quote has expired"></i>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Quote Date</span>
|
||||
<span class="mobile-card-value">@quote.QuoteDate.ToString("MM/dd/yyyy")</span>
|
||||
</div>
|
||||
@if (quote.ExpirationDate.HasValue)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Expires</span>
|
||||
<span class="mobile-card-value">@quote.ExpirationDate.Value.ToString("MM/dd/yyyy")</span>
|
||||
</div>
|
||||
}
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Total</span>
|
||||
<span class="mobile-card-value fw-semibold">@quote.Total.ToString("C")</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mobile-card-footer">
|
||||
<a href="@Url.Action("Details", "Quotes", new { id = quote.Id })"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
onclick="event.stopPropagation();">
|
||||
<i class="bi bi-eye me-1"></i>View
|
||||
</a>
|
||||
<a href="@Url.Action("Edit", "Quotes", new { id = quote.Id })"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
onclick="event.stopPropagation();">
|
||||
<i class="bi bi-pencil me-1"></i>Edit
|
||||
</a>
|
||||
@if (quote.IsProspect && quote.StatusCode == "APPROVED")
|
||||
{
|
||||
<a href="@Url.Action("ConvertToCustomer", "Quotes", new { id = quote.Id })"
|
||||
class="btn btn-sm btn-outline-success"
|
||||
onclick="event.stopPropagation();"
|
||||
title="Convert to Customer">
|
||||
<i class="bi bi-arrow-right-circle"></i>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-file-text" style="font-size: 4rem; color: #ccc;"></i>
|
||||
<p class="text-muted mt-3">No quotes found.</p>
|
||||
@if (!string.IsNullOrEmpty(searchTerm) || statusFilter.HasValue)
|
||||
{
|
||||
<p class="text-muted">Try adjusting your filters or <a asp-action="Index" asp-controller="Quotes">view all quotes</a>.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a asp-action="Create" asp-controller="Quotes" class="btn btn-primary mt-2">
|
||||
<i class="bi bi-plus-circle me-1"></i>Create Your First Quote
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (Model.TotalCount > 0)
|
||||
{
|
||||
@await Html.PartialAsync("_Pagination", Model)
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Antiforgery Token for AJAX -->
|
||||
@Html.AntiForgeryToken()
|
||||
|
||||
<!-- Status Change Modal -->
|
||||
<div class="modal fade" id="statusModal" tabindex="-1" aria-labelledby="statusModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="statusModalLabel">
|
||||
<i class="bi bi-flag me-2"></i>Change Quote Status
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="text-muted small mb-1">Quote Number</label>
|
||||
<p class="fw-semibold mb-0" id="modalQuoteNumber"></p>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="text-muted small mb-1">Current Status</label>
|
||||
<p class="mb-0" id="modalCurrentStatus"></p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="statusSelect" class="form-label">Select Status</label>
|
||||
<select id="statusSelect" class="form-select">
|
||||
<option value="">Loading statuses...</option>
|
||||
</select>
|
||||
</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" id="saveStatus">
|
||||
<i class="bi bi-save me-2"></i>Save Status
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script src="/js/quotes-calibration-nudge.js" asp-append-version="true"></script>
|
||||
<script>
|
||||
let currentQuoteId = null;
|
||||
let currentQuoteStatusId = null;
|
||||
let quoteStatuses = [];
|
||||
|
||||
async function loadQuoteStatuses() {
|
||||
try {
|
||||
const response = await fetch('/CompanySettings/GetQuoteStatuses');
|
||||
const data = await response.json();
|
||||
if (Array.isArray(data)) {
|
||||
quoteStatuses = data.sort((a, b) => a.displayOrder - b.displayOrder);
|
||||
populateStatusDropdown();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading quote statuses:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function populateStatusDropdown() {
|
||||
const select = document.getElementById('statusSelect');
|
||||
select.innerHTML = '';
|
||||
quoteStatuses.forEach(status => {
|
||||
if (status.isActive) {
|
||||
const option = document.createElement('option');
|
||||
option.value = status.id;
|
||||
option.textContent = status.displayName;
|
||||
select.appendChild(option);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadQuoteStatuses();
|
||||
|
||||
document.querySelectorAll('.status-badge').forEach(badge => {
|
||||
badge.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
currentQuoteId = this.getAttribute('data-quote-id');
|
||||
currentQuoteStatusId = this.getAttribute('data-status-id');
|
||||
document.getElementById('modalQuoteNumber').textContent = this.getAttribute('data-quote-number');
|
||||
document.getElementById('modalCurrentStatus').textContent = this.getAttribute('data-status-name');
|
||||
document.getElementById('statusSelect').value = currentQuoteStatusId;
|
||||
|
||||
new bootstrap.Modal(document.getElementById('statusModal')).show();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('saveStatus').addEventListener('click', function() {
|
||||
const statusId = document.getElementById('statusSelect').value;
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
|
||||
|
||||
fetch('@Url.Action("UpdateQuoteStatus", "Quotes")', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'RequestVerificationToken': token
|
||||
},
|
||||
body: JSON.stringify({
|
||||
quoteId: parseInt(currentQuoteId),
|
||||
statusId: parseInt(statusId)
|
||||
})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('statusModal')).hide();
|
||||
location.reload();
|
||||
} else {
|
||||
showError('Error updating quote status: ' + (data.message || 'Unknown error'), 'Update Failed');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showError('An error occurred while updating quote status', 'Update Failed');
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.quote-row').forEach(row => {
|
||||
row.addEventListener('click', function(e) {
|
||||
if (e.target.closest('a, button, input, .btn-group')) return;
|
||||
const quoteId = this.getAttribute('data-quote-id');
|
||||
if (quoteId) {
|
||||
window.location.href = '@Url.Action("Details", "Quotes")?id=' + quoteId;
|
||||
}
|
||||
});
|
||||
|
||||
row.addEventListener('mouseenter', function() {
|
||||
if (!this.classList.contains('table-warning')) this.style.backgroundColor = '#f8f9fa';
|
||||
});
|
||||
row.addEventListener('mouseleave', function() {
|
||||
this.style.backgroundColor = '';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
@model (int ItemIndex, List<PowderCoating.Application.DTOs.Quote.CreateQuoteItemCoatDto> Coats)
|
||||
|
||||
@{
|
||||
var itemIndex = Model.ItemIndex;
|
||||
var coats = Model.Coats ?? new List<PowderCoating.Application.DTOs.Quote.CreateQuoteItemCoatDto>();
|
||||
|
||||
// Debug: Log coat count
|
||||
var coatCountForDebug = coats.Count;
|
||||
|
||||
// If no coats exist, initialize with one default Single Stage coat
|
||||
if (!coats.Any())
|
||||
{
|
||||
coats.Add(new PowderCoating.Application.DTOs.Quote.CreateQuoteItemCoatDto
|
||||
{
|
||||
CoatName = "Single Stage",
|
||||
Sequence = 1,
|
||||
CoverageSqFtPerLb = 30,
|
||||
TransferEfficiency = 65
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
<!-- DEBUG: Item @itemIndex has @coats.Count coat(s) (original count: @coatCountForDebug) -->
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold d-block mb-2">
|
||||
<i class="bi bi-paint-bucket me-1"></i>Coating Layers
|
||||
</label>
|
||||
<div class="accordion" id="coatsAccordion_@itemIndex">
|
||||
@for (int coatIndex = 0; coatIndex < coats.Count; coatIndex++)
|
||||
{
|
||||
var coat = coats[coatIndex];
|
||||
var isExpanded = coatIndex == 0; // First coat expanded by default
|
||||
|
||||
<div class="accordion-item coat-accordion-item" data-coat-index="@coatIndex">
|
||||
<h2 class="accordion-header" id="coatHeading_@(itemIndex)_@(coatIndex)">
|
||||
<div class="d-flex align-items-center">
|
||||
<button class="accordion-button @(isExpanded ? "" : "collapsed") flex-grow-1" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#coat_@(itemIndex)_@(coatIndex)"
|
||||
aria-expanded="@(isExpanded ? "true" : "false")" aria-controls="coat_@(itemIndex)_@(coatIndex)">
|
||||
<span class="coat-header-text">@(coat.CoatName ?? $"Coating Layer {coatIndex + 1}")</span>
|
||||
</button>
|
||||
@if (coats.Count > 1)
|
||||
{
|
||||
<button type="button" class="btn btn-sm btn-outline-danger ms-2 me-2"
|
||||
onclick="removeCoat(@itemIndex, @coatIndex); event.stopPropagation();"
|
||||
title="Remove this coating layer">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</h2>
|
||||
<div id="coat_@(itemIndex)_@(coatIndex)" class="accordion-collapse collapse @(isExpanded ? "show" : "")"
|
||||
aria-labelledby="coatHeading_@(itemIndex)_@(coatIndex)"
|
||||
data-bs-parent="#coatsAccordion_@itemIndex">
|
||||
<div class="accordion-body">
|
||||
<input type="hidden" name="QuoteItems[@itemIndex].Coats[@coatIndex].Sequence" value="@coat.Sequence" />
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label fw-semibold">Coat Type <span class="text-danger">*</span></label>
|
||||
<select name="QuoteItems[@itemIndex].Coats[@coatIndex].CoatName"
|
||||
class="form-select coat-name-input"
|
||||
onchange="updateCoatHeader(@itemIndex, @coatIndex)"
|
||||
required>
|
||||
<option value="Primer" selected="@(coat.CoatName == "Primer")">Primer</option>
|
||||
<option value="Single Stage" selected="@(coat.CoatName == "Single Stage")">Single Stage</option>
|
||||
<option value="Base Coat" selected="@(coat.CoatName == "Base Coat")">Base Coat</option>
|
||||
<option value="Top Coat" selected="@(coat.CoatName == "Top Coat")">Top Coat</option>
|
||||
<option value="Additional Stage" selected="@(coat.CoatName == "Additional Stage")">Additional Stage</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Radio: Stock vs Custom -->
|
||||
@{
|
||||
var hasInventoryItem = coat.InventoryItemId.HasValue && coat.InventoryItemId.Value > 0;
|
||||
var isCustom = !string.IsNullOrEmpty(coat.ColorName) || coat.PowderCostPerLb.HasValue;
|
||||
var coatType = hasInventoryItem ? "stock" : (isCustom ? "custom" : "stock");
|
||||
}
|
||||
<div class="btn-group mb-3 w-100" role="group">
|
||||
<input type="radio" class="btn-check"
|
||||
name="coatPowderType_@(itemIndex)_@(coatIndex)"
|
||||
id="coatStock_@(itemIndex)_@(coatIndex)" value="stock"
|
||||
@(coatType == "stock" ? "checked" : "")
|
||||
onchange="toggleCoatPowderSelection(@itemIndex, @coatIndex, 'stock')">
|
||||
<label class="btn btn-outline-primary" for="coatStock_@(itemIndex)_@(coatIndex)">
|
||||
<i class="bi bi-box-seam me-1"></i>In-Stock Powder
|
||||
</label>
|
||||
|
||||
<input type="radio" class="btn-check"
|
||||
name="coatPowderType_@(itemIndex)_@(coatIndex)"
|
||||
id="coatCustom_@(itemIndex)_@(coatIndex)" value="custom"
|
||||
@(coatType == "custom" ? "checked" : "")
|
||||
onchange="toggleCoatPowderSelection(@itemIndex, @coatIndex, 'custom')">
|
||||
<label class="btn btn-outline-primary" for="coatCustom_@(itemIndex)_@(coatIndex)">
|
||||
<i class="bi bi-bag-plus me-1"></i>Custom Powder
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Stock Powder Section -->
|
||||
<div id="coatStockSection_@(itemIndex)_@(coatIndex)" style="display: @(coatType == "stock" ? "block" : "none")">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Select Powder from Inventory</label>
|
||||
<select name="QuoteItems[@itemIndex].Coats[@coatIndex].InventoryItemId"
|
||||
id="coatInventorySelect_@(itemIndex)_@(coatIndex)"
|
||||
class="form-select inventory-coat-select"
|
||||
data-selected-value="@(coat.InventoryItemId ?? 0)"
|
||||
onchange="onCoatInventorySelected(@itemIndex, @coatIndex)">
|
||||
<option value="">-- Select Powder --</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Coverage Rate</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="number"
|
||||
name="QuoteItems[@itemIndex].Coats[@coatIndex].CoverageSqFtPerLb"
|
||||
id="coatCoverage_@(itemIndex)_@(coatIndex)"
|
||||
class="form-control" value="@coat.CoverageSqFtPerLb"
|
||||
min="1" max="500" step="0.1"
|
||||
onchange="calculateCoatPowderNeeded(@itemIndex, @coatIndex)">
|
||||
<span class="input-group-text">sq ft/lb</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Transfer Efficiency</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="number"
|
||||
name="QuoteItems[@itemIndex].Coats[@coatIndex].TransferEfficiency"
|
||||
id="coatEfficiency_@(itemIndex)_@(coatIndex)"
|
||||
class="form-control" value="@coat.TransferEfficiency"
|
||||
min="1" max="100" step="1"
|
||||
onchange="calculateCoatPowderNeeded(@itemIndex, @coatIndex)">
|
||||
<span class="input-group-text">%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Powder Section -->
|
||||
<div id="coatCustomSection_@(itemIndex)_@(coatIndex)" style="display: @(coatType == "custom" ? "block" : "none")">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Color Name</label>
|
||||
<input type="text"
|
||||
name="QuoteItems[@itemIndex].Coats[@coatIndex].ColorName"
|
||||
id="coatColorName_@(itemIndex)_@(coatIndex)"
|
||||
value="@coat.ColorName"
|
||||
class="form-control"
|
||||
placeholder="e.g., Gloss Black RAL 9005"
|
||||
onchange="updateCoatHeader(@itemIndex, @coatIndex)" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Vendor</label>
|
||||
<select name="QuoteItems[@itemIndex].Coats[@coatIndex].VendorId"
|
||||
id="coatVendorSelect_@(itemIndex)_@(coatIndex)"
|
||||
class="form-select coat-vendor-select"
|
||||
data-selected-value="@(coat.VendorId ?? 0)">
|
||||
<option value="">-- Select Vendor --</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Powder to Order</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="number"
|
||||
name="QuoteItems[@itemIndex].Coats[@coatIndex].PowderToOrder"
|
||||
id="coatPowderToOrder_@(itemIndex)_@(coatIndex)"
|
||||
value="@coat.PowderToOrder"
|
||||
class="form-control" min="0" step="1"
|
||||
placeholder="Auto-calculated" />
|
||||
<span class="input-group-text">lbs</span>
|
||||
</div>
|
||||
<small class="text-muted">Auto-filled from powder needed (rounded up)</small>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Powder Cost</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text">$</span>
|
||||
<input type="number"
|
||||
name="QuoteItems[@itemIndex].Coats[@coatIndex].PowderCostPerLb"
|
||||
value="@coat.PowderCostPerLb"
|
||||
class="form-control" min="0" step="0.01"
|
||||
placeholder="0.00" />
|
||||
<span class="input-group-text">per lb</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Coverage Rate</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="number"
|
||||
name="QuoteItems[@itemIndex].Coats[@coatIndex].CoverageSqFtPerLb"
|
||||
id="coatCustomCoverage_@(itemIndex)_@(coatIndex)"
|
||||
class="form-control" value="@coat.CoverageSqFtPerLb"
|
||||
min="1" max="500" step="0.1"
|
||||
onchange="calculateCoatPowderNeeded(@itemIndex, @coatIndex)">
|
||||
<span class="input-group-text">sq ft/lb</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Transfer Efficiency</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="number"
|
||||
name="QuoteItems[@itemIndex].Coats[@coatIndex].TransferEfficiency"
|
||||
id="coatCustomEfficiency_@(itemIndex)_@(coatIndex)"
|
||||
class="form-control" value="@coat.TransferEfficiency"
|
||||
min="1" max="100" step="1"
|
||||
onchange="calculateCoatPowderNeeded(@itemIndex, @coatIndex)">
|
||||
<span class="input-group-text">%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Powder Needed Display (shown for calculated items only) -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-12 coat-powder-needed-display" id="coatPowderNeededContainer_@(itemIndex)_@(coatIndex)">
|
||||
<label class="form-label fw-semibold">Powder Needed for This Coat (Total Batch)</label>
|
||||
<div class="bg-success bg-opacity-10 border border-success rounded p-3 text-center">
|
||||
<strong id="coatPowderNeeded_@(itemIndex)_@(coatIndex)" class="fs-5 text-success">0.00 lbs</strong>
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
<i class="bi bi-info-circle"></i> This calculation is for the entire batch (all items × surface area)
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Coat Notes -->
|
||||
<div class="row g-3">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Notes (Optional)</label>
|
||||
<textarea name="QuoteItems[@itemIndex].Coats[@coatIndex].Notes"
|
||||
class="form-control" rows="2"
|
||||
placeholder="Special instructions for this coating layer...">@coat.Notes</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Add Coating Layer Button -->
|
||||
<div class="mt-3">
|
||||
<button type="button" class="btn btn-sm btn-outline-success"
|
||||
onclick="addCoat(@itemIndex)">
|
||||
<i class="bi bi-plus-circle me-1"></i> Add Coating Layer
|
||||
</button>
|
||||
<small class="text-muted ms-2">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
First coat = 100% labor, each additional = +30% labor
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user