Initial commit
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
@model PowderCoating.Application.DTOs.Quote.ConvertQuoteToCustomerDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Convert Prospect/Walk-In to Customer";
|
||||
ViewData["PageIcon"] = "bi-arrow-right-circle-fill";
|
||||
}
|
||||
|
||||
<div class="container-fluid mt-4">
|
||||
<div class="mb-4">
|
||||
<p class="text-muted">Quote: <strong>@Model.QuoteNumber</strong></p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<form asp-action="ConvertToCustomer" method="post">
|
||||
<input type="hidden" asp-for="QuoteId" />
|
||||
<input type="hidden" asp-for="QuoteNumber" />
|
||||
<partial name="_ValidationSummary" />
|
||||
|
||||
<!-- Quote Summary (Read-Only) -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-info text-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-file-text me-2"></i>Quote Summary
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<p><strong>Quote Number:</strong> @Model.QuoteNumber</p>
|
||||
<p><strong>Current Prospect/Walk-In:</strong> @(Model.CompanyName ?? Model.ContactName ?? "Unknown")</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<p><strong>Email:</strong> @(Model.Email ?? "-")</p>
|
||||
<p><strong>Phone:</strong> @(Model.Phone ?? "-")</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-warning mb-0">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
<strong>Note:</strong> Converting this prospect will create a new customer record
|
||||
and link this quote to that customer. The quote status will change to "Converted".
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Customer Information -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-building me-2"></i>Customer Information
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="mb-3">
|
||||
<label asp-for="IsCommercial" class="form-label">Customer Type</label>
|
||||
<select asp-for="IsCommercial" class="form-select" id="customerTypeSelect" onchange="toggleCompanyNameRequired()">
|
||||
<option value="false">Individual</option>
|
||||
<option value="true">Commercial</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label asp-for="CompanyName" class="form-label">
|
||||
Company Name <span id="companyNameRequired" class="text-danger" style="display:none;">*</span>
|
||||
</label>
|
||||
<input asp-for="CompanyName" class="form-control" />
|
||||
<span asp-validation-for="CompanyName" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label asp-for="ContactName" class="form-label">Contact Name <span class="text-danger">*</span></label>
|
||||
<input asp-for="ContactName" class="form-control" placeholder="First and last name" />
|
||||
<span asp-validation-for="ContactName" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12 mb-2">
|
||||
<small class="text-muted"><span class="text-danger">*</span> At least one of email or phone is required.</small>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label asp-for="Email" class="form-label">Email</label>
|
||||
<input asp-for="Email" class="form-control" type="email" placeholder="customer@example.com" />
|
||||
<span asp-validation-for="Email" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label asp-for="Phone" class="form-label">Phone</label>
|
||||
<input asp-for="Phone" class="form-control" type="tel" placeholder="(555) 000-0000" />
|
||||
<span asp-validation-for="Phone" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Address Information -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-geo-alt me-2"></i>Address Information
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="mb-3">
|
||||
<label asp-for="Address" class="form-label"></label>
|
||||
<input asp-for="Address" class="form-control" />
|
||||
<span asp-validation-for="Address" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label asp-for="City" class="form-label"></label>
|
||||
<input asp-for="City" class="form-control" />
|
||||
<span asp-validation-for="City" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="mb-3">
|
||||
<label asp-for="State" class="form-label"></label>
|
||||
<input asp-for="State" class="form-control" maxlength="2" />
|
||||
<span asp-validation-for="State" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="mb-3">
|
||||
<label asp-for="ZipCode" class="form-label"></label>
|
||||
<input asp-for="ZipCode" class="form-control" />
|
||||
<span asp-validation-for="ZipCode" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Customer Details -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-gear me-2"></i>Additional Details
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label asp-for="TaxId" class="form-label"></label>
|
||||
<input asp-for="TaxId" class="form-control" />
|
||||
<span asp-validation-for="TaxId" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label asp-for="PaymentTerms" class="form-label"></label>
|
||||
<select asp-for="PaymentTerms" class="form-select">
|
||||
<option value="">Select payment terms</option>
|
||||
<option value="Net 15">Net 15</option>
|
||||
<option value="Net 30">Net 30</option>
|
||||
<option value="Net 45">Net 45</option>
|
||||
<option value="Net 60">Net 60</option>
|
||||
<option value="Due on Receipt">Due on Receipt</option>
|
||||
<option value="Cash on Delivery">Cash on Delivery</option>
|
||||
</select>
|
||||
<span asp-validation-for="PaymentTerms" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label asp-for="CreditLimit" class="form-label"></label>
|
||||
<input asp-for="CreditLimit" class="form-control" type="number" step="0.01" />
|
||||
<span asp-validation-for="CreditLimit" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label asp-for="PricingTierId" class="form-label"></label>
|
||||
<select asp-for="PricingTierId" class="form-select" asp-items="ViewBag.PricingTiers">
|
||||
<option value="">-- No Pricing Tier --</option>
|
||||
</select>
|
||||
<span asp-validation-for="PricingTierId" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="mb-3">
|
||||
<label asp-for="Notes" class="form-label"></label>
|
||||
<textarea asp-for="Notes" class="form-control" rows="3"></textarea>
|
||||
<span asp-validation-for="Notes" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="mb-4">
|
||||
<button type="submit" class="btn btn-success btn-lg">
|
||||
<i class="bi bi-arrow-right-circle me-1"></i>Convert to Customer
|
||||
</button>
|
||||
<a asp-action="Details" asp-route-id="@Model.QuoteId" class="btn btn-outline-secondary btn-lg">
|
||||
<i class="bi bi-x-circle me-1"></i>Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Help Info -->
|
||||
<div class="col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-info-circle me-2"></i>What Happens Next?
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ol class="mb-0">
|
||||
<li class="mb-2">
|
||||
<strong>Customer Created:</strong> A new customer record will be created with a unique customer number.
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>Quote Updated:</strong> The quote will be linked to the new customer and marked as "Converted".
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>Future Quotes:</strong> You can now create additional quotes for this customer.
|
||||
</li>
|
||||
<li class="mb-0">
|
||||
<strong>Jobs:</strong> You'll be able to convert quotes to jobs for this customer.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-lightbulb me-2"></i>Tips
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="mb-0">
|
||||
<li class="mb-2">
|
||||
Review all contact information carefully before converting.
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
Set an appropriate credit limit if offering credit terms.
|
||||
</li>
|
||||
<li class="mb-0">
|
||||
Assign a pricing tier to give this customer automatic discounts on future quotes.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
|
||||
<script>
|
||||
function toggleCompanyNameRequired() {
|
||||
const isCommercial = document.getElementById('IsCommercial').value === 'true';
|
||||
const $companyName = $('#CompanyName');
|
||||
const $requiredMark = $('#companyNameRequired');
|
||||
|
||||
if (isCommercial) {
|
||||
$requiredMark.show();
|
||||
$companyName.rules('add', { required: true, messages: { required: 'Company Name is required for commercial customers.' } });
|
||||
} else {
|
||||
$requiredMark.hide();
|
||||
$companyName.rules('add', { required: false });
|
||||
$companyName.valid(); // clear any existing error
|
||||
}
|
||||
}
|
||||
|
||||
$(document).ready(function () {
|
||||
toggleCompanyNameRequired();
|
||||
});
|
||||
</script>
|
||||
}
|
||||
@@ -0,0 +1,783 @@
|
||||
@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" asp-for="TaxPercent" />
|
||||
|
||||
<!-- 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">
|
||||
<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">
|
||||
<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">
|
||||
<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 -->
|
||||
@if (ViewBag.QuotePhotosEnabled is bool qpe && qpe)
|
||||
{
|
||||
<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="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">
|
||||
<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>
|
||||
<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">
|
||||
<i class="bi bi-check-circle me-1"></i>Create Quote
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Surface Area Calculator Modal -->
|
||||
<div class="modal fade" id="sqFtCalculatorModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-calculator me-2"></i>Surface Area Calculator <small class="text-muted">(per item)</small></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Shape</label>
|
||||
<select id="calcShape" class="form-select" onchange="toggleShapeInputs()">
|
||||
<option value="rectangle">Rectangle / Square</option>
|
||||
<option value="cylinder">Cylinder (Tube)</option>
|
||||
<option value="circle">Circle (Flat)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="rectangleInputs">
|
||||
<div class="row g-2">
|
||||
<div class="col-6"><label class="form-label">Length (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
||||
<input type="number" id="rectLength" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
||||
<div class="col-6"><label class="form-label">Width (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
||||
<input type="number" id="rectWidth" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
||||
</div>
|
||||
<small class="text-muted">Formula: L × W ÷ @(ViewBag.UseMetric == true ? "10,000" : "144")</small>
|
||||
</div>
|
||||
<div id="cylinderInputs" style="display:none">
|
||||
<div class="row g-2">
|
||||
<div class="col-6"><label class="form-label">Diameter (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
||||
<input type="number" id="cylDiameter" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
||||
<div class="col-6"><label class="form-label">Height (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
||||
<input type="number" id="cylHeight" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="circleInputs" style="display:none">
|
||||
<label class="form-label">Diameter (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
||||
<input type="number" id="circDiameter" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()">
|
||||
</div>
|
||||
<hr />
|
||||
<div class="alert alert-info mb-0"><strong>Result:</strong> <span id="calcResult">0.00</span> @ViewBag.AreaUnit</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="useSqFtResult()">
|
||||
<i class="bi bi-check-circle me-1"></i>Use This Value
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========================= ITEM WIZARD MODAL ========================= -->
|
||||
<div class="modal fade" id="itemWizardModal" tabindex="-1" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="d-flex flex-column">
|
||||
<h5 class="modal-title mb-0" id="wizardTitle">Add Item</h5>
|
||||
<div class="text-muted small mb-1" id="wizardStepTitle">Choose Item Type</div>
|
||||
<!-- Step progress -->
|
||||
<div class="d-flex align-items-center gap-2" id="wizardStepIndicator">
|
||||
<span class="wizard-step-dot active" data-step="1" title="Item Type"></span>
|
||||
<div class="wizard-step-line"></div>
|
||||
<span class="wizard-step-dot" data-step="2" title="Item Details"></span>
|
||||
<div class="wizard-step-line" id="step2Line"></div>
|
||||
<span class="wizard-step-dot" data-step="3" title="Coating Layers" id="step3Dot"></span>
|
||||
<div class="wizard-step-line" id="step3Line"></div>
|
||||
<span class="wizard-step-dot" data-step="4" title="Prep Services" id="step4Dot"></span>
|
||||
<span class="text-muted small ms-2" id="wizardStepLabel">Step 1 of 4</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close ms-auto" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="wizardBody" style="min-height: 300px;">
|
||||
<!-- Content injected by JS -->
|
||||
</div>
|
||||
<div class="modal-footer justify-content-between">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary d-none" id="btnWizardBack" onclick="wizardBack()">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" id="btnWizardNext" onclick="wizardNext()">
|
||||
Next <i class="bi bi-arrow-right ms-1"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-success d-none" id="btnWizardSave" onclick="wizardSave()">
|
||||
<i class="bi bi-check-lg me-1"></i>Add Item
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Embedded data for JS -->
|
||||
@if (ViewBag.InventoryCoatings != null)
|
||||
{
|
||||
<script id="inventoryPowdersData" type="application/json">
|
||||
@Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.InventoryCoatings))
|
||||
</script>
|
||||
}
|
||||
@if (ViewBag.CatalogItems != null)
|
||||
{
|
||||
<script id="catalogItemsData" type="application/json">
|
||||
@Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.CatalogItems))
|
||||
</script>
|
||||
}
|
||||
<script id="merchandiseItemsData" type="application/json">
|
||||
@Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.MerchandiseItems ?? new List<object>()))
|
||||
</script>
|
||||
<script id="vendorsData" type="application/json">
|
||||
@Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.Vendors ?? new List<object>()))
|
||||
</script>
|
||||
<script id="prepServicesData" type="application/json">
|
||||
@Html.Raw(Json.Serialize(((List<PrepService>)(ViewBag.PrepServices ?? new List<PrepService>())).Select(p => new { id = p.Id, name = p.ServiceName, description = p.Description, requiresBlastSetup = p.RequiresBlastSetup })))
|
||||
</script>
|
||||
<script id="blastSetupsData" type="application/json">
|
||||
@Html.Raw(Json.Serialize(ViewBag.BlastSetups ?? new List<object>()))
|
||||
</script>
|
||||
|
||||
<!-- Existing items (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>())),
|
||||
"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>
|
||||
/* Wizard step indicator */
|
||||
.wizard-step-dot {
|
||||
width: 22px; height: 22px; border-radius: 50%;
|
||||
background: #dee2e6; display: inline-block; cursor: default;
|
||||
border: 2px solid #dee2e6; transition: all .2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wizard-step-dot.active { background: #0d6efd; border-color: #0d6efd; }
|
||||
.wizard-step-dot.done { background: #198754; border-color: #198754; }
|
||||
.wizard-step-dot.skip { background: #adb5bd; border-color: #adb5bd; }
|
||||
.wizard-step-line { flex: 1; height: 2px; background: #dee2e6; min-width: 30px; }
|
||||
/* Item type picker cards */
|
||||
.item-type-card {
|
||||
border: 2px solid #dee2e6; border-radius: .75rem; padding: 1.25rem 1rem;
|
||||
cursor: pointer; transition: all .15s; text-align: center;
|
||||
background: #fff; user-select: none;
|
||||
}
|
||||
.item-type-card:hover { border-color: #86b7fe; background: #f0f6ff; }
|
||||
.item-type-card.selected { border-color: #0d6efd; background: #eef3ff; }
|
||||
.item-type-card .item-type-icon { font-size: 2rem; margin-bottom: .5rem; }
|
||||
[data-bs-theme="dark"] .item-type-card { background: var(--bs-tertiary-bg); border-color: var(--bs-border-color); color: var(--bs-body-color); }
|
||||
[data-bs-theme="dark"] .item-type-card:hover { border-color: #86b7fe; background: var(--bs-secondary-bg); }
|
||||
[data-bs-theme="dark"] .item-type-card.selected { border-color: #0d6efd; background: #1a2a4a; }
|
||||
/* Catalog listbox (replaces native <select> for cross-platform filter support) */
|
||||
.catalog-list-item { cursor: pointer; border-bottom: 1px solid var(--bs-border-color); font-size: .9rem; transition: background .1s; }
|
||||
.catalog-list-item:last-child { border-bottom: none; }
|
||||
.catalog-list-item:hover { background: var(--bs-tertiary-bg); }
|
||||
.catalog-list-item.selected { background: #eef3ff; color: #0d6efd; font-weight: 600; }
|
||||
[data-bs-theme="dark"] .catalog-list-item.selected { background: #1a2a4a; color: #86b7fe; }
|
||||
/* Summary cards */
|
||||
.quote-item-card {
|
||||
border: 1px solid #dee2e6; border-radius: .5rem;
|
||||
padding: .75rem 1rem; margin-bottom: .5rem;
|
||||
background: #fafafa;
|
||||
}
|
||||
.quote-item-card .item-badge { font-size: .7rem; }
|
||||
/* Coat rows in wizard */
|
||||
.coat-row { border: 1px solid #dee2e6; border-radius: .5rem; padding: .75rem; margin-bottom: .5rem; }
|
||||
[data-bs-theme="dark"] .quote-item-card { background: var(--bs-tertiary-bg); border-color: var(--bs-border-color); color: var(--bs-body-color); }
|
||||
[data-bs-theme="dark"] .coat-row { background: var(--bs-tertiary-bg); border-color: var(--bs-border-color); }
|
||||
</style>
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
|
||||
<script src="~/lib/tom-select/js/tom-select.complete.min.js"></script>
|
||||
<script src="~/js/item-wizard.js?v=@DateTime.Now.Ticks"></script>
|
||||
<script>
|
||||
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 when customer changes to/from a tax-exempt customer
|
||||
function onQuoteCustomerChanged(select) {
|
||||
const meta = JSON.parse(document.getElementById('quoteMetaData').textContent);
|
||||
const exemptIds = new Set(meta.taxExemptCustomerIds || []);
|
||||
const customerId = parseInt(select.value) || 0;
|
||||
const taxField = document.querySelector('[name="TaxPercent"]');
|
||||
if (taxField) {
|
||||
taxField.value = exemptIds.has(customerId) ? 0 : (meta.companyTaxPercent ?? meta.taxPercent);
|
||||
}
|
||||
}
|
||||
|
||||
// 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');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Discount type toggle
|
||||
function onDiscountTypeChange() {
|
||||
const type = document.getElementById('discountTypeSelect').value;
|
||||
const show = type !== 'None';
|
||||
document.getElementById('discountValueSection').style.display = show ? 'block' : 'none';
|
||||
document.getElementById('discountReasonSection').style.display = show ? 'block' : 'none';
|
||||
document.getElementById('hideDiscountSection').style.display = show ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Surface area calculator
|
||||
let _sqFtTargetInput = null;
|
||||
function openSqFtCalculator(inputId) {
|
||||
_sqFtTargetInput = inputId;
|
||||
document.getElementById('rectLength').value = 0;
|
||||
document.getElementById('rectWidth').value = 0;
|
||||
document.getElementById('calcResult').textContent = '0.00';
|
||||
new bootstrap.Modal(document.getElementById('sqFtCalculatorModal')).show();
|
||||
}
|
||||
function toggleShapeInputs() {
|
||||
const shape = document.getElementById('calcShape').value;
|
||||
document.getElementById('rectangleInputs').style.display = shape === 'rectangle' ? 'block' : 'none';
|
||||
document.getElementById('cylinderInputs').style.display = shape === 'cylinder' ? 'block' : 'none';
|
||||
document.getElementById('circleInputs').style.display = shape === 'circle' ? 'block' : 'none';
|
||||
calculateSqFt();
|
||||
}
|
||||
function calculateSqFt() {
|
||||
const useMetric = @Json.Serialize((bool)(ViewBag.UseMetric ?? false));
|
||||
const divisor = useMetric ? 10000 : 144;
|
||||
const shape = document.getElementById('calcShape').value;
|
||||
let result = 0;
|
||||
if (shape === 'rectangle') {
|
||||
const l = parseFloat(document.getElementById('rectLength').value) || 0;
|
||||
const w = parseFloat(document.getElementById('rectWidth').value) || 0;
|
||||
result = (l * w) / divisor;
|
||||
} else if (shape === 'cylinder') {
|
||||
const d = parseFloat(document.getElementById('cylDiameter').value) || 0;
|
||||
const h = parseFloat(document.getElementById('cylHeight').value) || 0;
|
||||
const r = d / 2;
|
||||
result = (2 * Math.PI * r * r + 2 * Math.PI * r * h) / divisor;
|
||||
} else {
|
||||
const d = parseFloat(document.getElementById('circDiameter').value) || 0;
|
||||
const r = d / 2;
|
||||
result = (Math.PI * r * r) / divisor;
|
||||
}
|
||||
document.getElementById('calcResult').textContent = result.toFixed(4);
|
||||
}
|
||||
function useSqFtResult() {
|
||||
const val = document.getElementById('calcResult').textContent;
|
||||
if (_sqFtTargetInput) {
|
||||
const el = document.getElementById(_sqFtTargetInput) || document.querySelector(`[name="${_sqFtTargetInput}"]`);
|
||||
if (el) { el.value = parseFloat(val).toFixed(2); el.dispatchEvent(new Event('change')); }
|
||||
}
|
||||
bootstrap.Modal.getInstance(document.getElementById('sqFtCalculatorModal'))?.hide();
|
||||
}
|
||||
|
||||
// Form submit guard
|
||||
document.getElementById('quoteForm').addEventListener('submit', function(e) {
|
||||
if (typeof quoteItems === 'undefined' || quoteItems.length === 0) {
|
||||
e.preventDefault();
|
||||
alert('Please add at least one item before submitting.');
|
||||
}
|
||||
});
|
||||
|
||||
// Quote photo staging (temp upload before quoteId exists)
|
||||
(function () {
|
||||
const uploadUrl = '@Url.Action("UploadQuotePhotoTemp", "Quotes")';
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
const stagedPhotos = []; // {tempId, fileName}
|
||||
|
||||
const fileInput = document.getElementById('quotePhotoStagingInput');
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', () => {
|
||||
for (const file of fileInput.files) stagePhoto(file);
|
||||
fileInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
async function stagePhoto(file) {
|
||||
const progress = document.getElementById('stagedPhotoUploadProgress');
|
||||
progress?.classList.remove('d-none');
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('__RequestVerificationToken', token);
|
||||
|
||||
const resp = await fetch(uploadUrl, { method: 'POST', body: fd });
|
||||
const data = await resp.json();
|
||||
progress?.classList.add('d-none');
|
||||
|
||||
if (!data.success) { alert(data.error || 'Upload failed.'); return; }
|
||||
|
||||
stagedPhotos.push({ tempId: data.tempId, fileName: data.fileName });
|
||||
document.getElementById('stagedNoPhotosMsg')?.remove();
|
||||
|
||||
const grid = document.getElementById('stagedPhotoGrid');
|
||||
const idx = stagedPhotos.length - 1;
|
||||
const col = document.createElement('div');
|
||||
col.className = 'col-sm-6 col-md-4 col-lg-3';
|
||||
col.id = 'staged-' + idx;
|
||||
col.innerHTML = `
|
||||
<div class="card h-100 position-relative">
|
||||
<button type="button" class="btn btn-sm btn-danger position-absolute top-0 end-0 m-1 p-0 px-1"
|
||||
style="z-index:1;font-size:.7rem;" onclick="removeStagedPhoto(${idx})" title="Remove">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
<div class="d-flex align-items-center justify-content-center bg-light" style="height:160px;">
|
||||
<i class="bi bi-image text-muted" style="font-size:2rem;"></i>
|
||||
</div>
|
||||
<div class="card-body p-2">
|
||||
<p class="card-text small text-muted text-truncate mb-0" title="${data.fileName}">${data.fileName}</p>
|
||||
<p class="card-text text-muted mb-0" style="font-size:.7rem;">${data.fileSizeDisplay}</p>
|
||||
</div>
|
||||
</div>`;
|
||||
grid.appendChild(col);
|
||||
|
||||
const badge = document.getElementById('stagedPhotoCount');
|
||||
if (badge) badge.textContent = stagedPhotos.filter(p => p).length;
|
||||
|
||||
rebuildHiddenFields();
|
||||
}
|
||||
|
||||
window.removeStagedPhoto = function (idx) {
|
||||
stagedPhotos[idx] = null;
|
||||
document.getElementById('staged-' + idx)?.remove();
|
||||
const badge = document.getElementById('stagedPhotoCount');
|
||||
if (badge) badge.textContent = stagedPhotos.filter(p => p).length;
|
||||
rebuildHiddenFields();
|
||||
};
|
||||
|
||||
function rebuildHiddenFields() {
|
||||
const container = document.getElementById('quotePhotoTempIdsContainer');
|
||||
if (!container) return;
|
||||
const valid = stagedPhotos.filter(p => p);
|
||||
container.innerHTML = valid.map((p, i) =>
|
||||
`<input type="hidden" name="QuotePhotoTempIds[${i}]" value="${p.tempId}">` +
|
||||
`<input type="hidden" name="QuotePhotoFileNames[${i}]" value="${p.fileName}">`
|
||||
).join('');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
}
|
||||
@@ -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,779 @@
|
||||
@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 (Read-Only) -->
|
||||
<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" />
|
||||
<input type="hidden" asp-for="CustomerId" />
|
||||
|
||||
@if (Model.IsForProspect)
|
||||
{
|
||||
<!-- Prospect Information (Editable) -->
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<label asp-for="ProspectCompanyName" class="form-label"></label>
|
||||
<input asp-for="ProspectCompanyName" class="form-control" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label asp-for="ProspectContactName" class="form-label">Contact Name <span class="text-danger">*</span></label>
|
||||
<input asp-for="ProspectContactName" class="form-control" />
|
||||
<span asp-validation-for="ProspectContactName" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
<div class="col-md-6">
|
||||
<label asp-for="ProspectEmail" class="form-label"></label>
|
||||
<input asp-for="ProspectEmail" class="form-control" type="email" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label asp-for="ProspectPhone" class="form-label">Phone <span class="text-danger">*</span></label>
|
||||
<input asp-for="ProspectPhone" class="form-control" type="tel" />
|
||||
<span asp-validation-for="ProspectPhone" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
<div class="col-md-6">
|
||||
<label asp-for="ProspectAddress" class="form-label"></label>
|
||||
<input asp-for="ProspectAddress" class="form-control" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label asp-for="ProspectCity" class="form-label"></label>
|
||||
<input asp-for="ProspectCity" class="form-control" />
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label asp-for="ProspectState" class="form-label"></label>
|
||||
<input asp-for="ProspectState" class="form-control" maxlength="2" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label asp-for="ProspectZipCode" class="form-label"></label>
|
||||
<input asp-for="ProspectZipCode" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<!-- Existing Customer (Read-Only Display) -->
|
||||
<div class="alert alert-light alert-permanent border mb-0 d-flex align-items-center gap-2">
|
||||
<i class="bi bi-building text-success fs-5"></i>
|
||||
<div>
|
||||
<span class="fw-semibold">@ViewBag.CustomerName</span>
|
||||
<span class="text-muted ms-2 small">Customer cannot be changed after quote creation.</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 2: Quote Information -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-info-circle me-2"></i>Quote Information
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right"
|
||||
data-bs-title="Quote Information"
|
||||
data-bs-content="Set the quote date, expiration, and any internal notes. The <strong>Expiration Date</strong> is shown to the customer — once it passes the quote is flagged Expired and can no longer be approved without editing. The <strong>Customer PO</strong> field is optional — use it if the customer provides their own purchase order number.<br><br><a href='/Help/Quotes#quote-statuses' target='_blank'>Learn more →</a>">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input asp-for="IsRushJob" class="form-check-input" type="checkbox" id="IsRushJob" onchange="scheduleAutoPricing()">
|
||||
<label class="form-check-label" for="IsRushJob">
|
||||
<strong>Rush Job</strong> <small class="text-muted">(additional fee applies)</small>
|
||||
</label>
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right"
|
||||
data-bs-title="Rush Job"
|
||||
data-bs-content="Marks this quote as high priority. A rush fee is added to the pricing total based on the rate configured in Settings. The job will also be highlighted in the jobs list so your team knows to prioritise it.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<label asp-for="QuoteDate" class="form-label"></label>
|
||||
<input asp-for="QuoteDate" class="form-control" type="date" />
|
||||
<span asp-validation-for="QuoteDate" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label asp-for="ExpirationDate" class="form-label"></label>
|
||||
<input asp-for="ExpirationDate" class="form-control" type="date" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
<div class="col-md-6">
|
||||
<label asp-for="Description" class="form-label"></label>
|
||||
<input asp-for="Description" class="form-control" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label asp-for="CustomerPO" class="form-label"></label>
|
||||
<input asp-for="CustomerPO" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
<div class="col-md-6">
|
||||
<label asp-for="Notes" class="form-label"></label>
|
||||
<textarea asp-for="Notes" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Tags <span class="badge rounded-pill bg-warning text-dark ms-1" style="font-size:0.65rem;">Recommended</span></label>
|
||||
<p class="text-muted small mb-1">Tags are used to improve smart predictions and assistance over time. The more consistently you tag, the smarter the system gets.</p>
|
||||
<input type="hidden" asp-for="Tags" id="quoteTags" />
|
||||
<div id="quoteTagsContainer"></div>
|
||||
<small class="text-muted">Press <kbd>Enter</kbd> or <kbd>,</kbd> to add a tag. Click × to remove.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 3: Oven & Batch Pricing -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-fire me-2"></i>Oven & Batch Pricing
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right"
|
||||
data-bs-title="Oven & Batch Pricing"
|
||||
data-bs-content="The oven cost is charged once per batch at the quote level, not per item. Estimate how many oven loads the full job will fill — for example, if you have 20 small parts and your oven fits 10, that's 2 batches. Cycle time is how long each batch runs. The cost is calculated from your oven's hourly rate in Settings.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-3">Estimate how many oven loads the complete job will require. The oven cycle cost is added once at the quote level, not per item.</p>
|
||||
<div class="row g-3 align-items-end">
|
||||
@if (ViewBag.OvenCosts != null && ((List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.OvenCosts).Count > 1)
|
||||
{
|
||||
<div class="col-md-4">
|
||||
<label asp-for="OvenCostId" class="form-label fw-semibold"><i class="bi bi-thermometer-half me-1"></i>Oven</label>
|
||||
<select asp-for="OvenCostId" asp-items="@((List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.OvenCosts)"
|
||||
class="form-select" onchange="scheduleAutoPricing()"></select>
|
||||
</div>
|
||||
}
|
||||
<div class="col-sm-3 col-md-2">
|
||||
<label asp-for="OvenBatches" class="form-label fw-semibold">Batches</label>
|
||||
<input asp-for="OvenBatches" class="form-control" type="number" min="1"
|
||||
value="@(Model.OvenBatches > 0 ? Model.OvenBatches : 1)"
|
||||
id="OvenBatches" onchange="scheduleAutoPricing()" />
|
||||
</div>
|
||||
<div class="col-sm-4 col-md-3">
|
||||
<label asp-for="OvenCycleMinutes" class="form-label fw-semibold">
|
||||
Cycle Time per Batch (min)
|
||||
<small class="text-muted fw-normal">default: @(ViewBag.DefaultOvenCycleMinutes ?? 45)</small>
|
||||
</label>
|
||||
<input asp-for="OvenCycleMinutes" class="form-control" type="number" min="1"
|
||||
id="OvenCycleMinutes" placeholder="@(ViewBag.DefaultOvenCycleMinutes ?? 45)"
|
||||
onchange="scheduleAutoPricing()" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 4: Quote Items (Wizard) -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex align-items-center justify-content-between">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-list-ul me-2"></i>Quote Items
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right"
|
||||
data-bs-title="Quote Item Types"
|
||||
data-bs-content="<strong>Calculated</strong> — you enter surface area (sq ft) and the system prices it using your rates for materials, labour, and overhead.<br><strong>Custom Work</strong> — you enter a description and a manual price. Use this for flat-rate jobs or work that doesn't fit the formula.<br><strong>AI Photo</strong> — upload photos and let the AI estimate surface area and complexity for you.<br><br><a href='/Help/Quotes#quote-items' target='_blank'>Learn more →</a>">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</h5>
|
||||
<button type="button" class="btn btn-primary" onclick="openWizard()">
|
||||
<i class="bi bi-plus-circle me-1"></i>Add Item
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="itemsEmptyMessage" class="text-center py-5 text-muted" style="display: none;">
|
||||
<i class="bi bi-box-seam display-4 d-block mb-2 opacity-25"></i>
|
||||
<p class="mb-0">No items added yet.</p>
|
||||
<p class="small">Click <strong>Add Item</strong> to get started.</p>
|
||||
</div>
|
||||
<div id="itemCardsContainer"></div>
|
||||
|
||||
<!-- Hidden fields written by wizard JS -->
|
||||
<div id="hiddenFieldsContainer"></div>
|
||||
<!-- AI photo temp IDs written by wizard JS -->
|
||||
<div id="aiPhotoTempIdsContainer"></div>
|
||||
<!-- Quote photo temp IDs written by staging JS -->
|
||||
<div id="quotePhotoTempIdsContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 4: Discount -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-tag me-2"></i>Discount <small class="text-muted fw-normal">(optional)</small>
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right"
|
||||
data-bs-title="Quote Discount"
|
||||
data-bs-content="Apply a one-off discount to this quote. Choose <strong>Percentage</strong> to reduce the total by a % (e.g., 10%), or <strong>Fixed Amount</strong> to deduct a set dollar value. This is separate from any pricing tier discount the customer automatically receives. Add a reason so your team knows why the discount was given.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label asp-for="DiscountType" class="form-label fw-semibold">Discount Type</label>
|
||||
<select asp-for="DiscountType" class="form-select" id="discountTypeSelect" onchange="onDiscountTypeChange(); scheduleAutoPricing()">
|
||||
<option value="None">No Discount</option>
|
||||
<option value="Percentage">Percentage (%)</option>
|
||||
<option value="FixedAmount">Fixed Amount ($)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4" id="discountValueSection" style="display: @(Model.DiscountType == "None" ? "none" : "block")">
|
||||
<label class="form-label fw-semibold">Discount Value</label>
|
||||
<input asp-for="DiscountValue" type="number" class="form-control" id="discountValueInput"
|
||||
min="0" step="0.01" onchange="scheduleAutoPricing()" />
|
||||
</div>
|
||||
<div class="col-md-4" id="discountReasonSection" style="display: @(Model.DiscountType == "None" ? "none" : "block")">
|
||||
<label asp-for="DiscountReason" class="form-label fw-semibold">Reason</label>
|
||||
<input asp-for="DiscountReason" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
<div id="hideDiscountSection" class="mt-2" style="display: @(Model.DiscountType == "None" ? "none" : "block")">
|
||||
<div class="form-check">
|
||||
<input asp-for="HideDiscountFromCustomer" class="form-check-input" type="checkbox" id="hideDiscountFromCustomer" />
|
||||
<label class="form-check-label small" for="hideDiscountFromCustomer">
|
||||
Hide discount from customer — PDFs and approval portal show final price only
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 5: Pricing Summary -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-primary text-white d-flex align-items-center justify-content-between">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-calculator me-2"></i>Pricing Summary
|
||||
<a tabindex="0" class="help-icon text-white" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="left"
|
||||
data-bs-title="Pricing Summary"
|
||||
data-bs-content="The total is built up from materials, labour, equipment time, overhead, and profit margin — all based on the rates in Settings. A <strong>Tier Discount</strong> appears automatically if the customer has a pricing tier assigned. A <strong>Rush Fee</strong> is added when Rush Job is checked.<br><br><a href='/Help/Quotes#pricing-breakdown' target='_blank'>Learn more →</a>">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</h5>
|
||||
<span id="pricingSpinner" class="spinner-border spinner-border-sm text-white d-none" role="status"></span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="text-end" id="pricingSummary">
|
||||
<p class="mb-1 text-muted small" id="pricingPlaceholder">Pricing will update automatically as you add items.</p>
|
||||
<p class="mb-1 d-none" id="itemsSubtotalRow">Items Subtotal: <strong id="itemsSubtotalDisplay">$0.00</strong></p>
|
||||
<p class="mb-1 d-none" id="ovenBatchCostRow">
|
||||
<i class="bi bi-fire me-1"></i>Oven (<span id="ovenBatchesDisplay">1</span> batch × <span id="ovenCycleMinDisplay">45</span> min):
|
||||
<strong id="ovenBatchCostDisplay">$0.00</strong>
|
||||
</p>
|
||||
<p class="mb-1 text-success d-none" id="pricingTierDiscountRow">
|
||||
Tier Discount (<span id="pricingTierDiscountPercentDisplay">0</span>%):
|
||||
<strong id="pricingTierDiscountDisplay">-$0.00</strong>
|
||||
</p>
|
||||
<p class="mb-1 text-success d-none" id="quoteDiscountRow">
|
||||
Quote Discount (<span id="quoteDiscountPercentDisplay">0</span>%):
|
||||
<strong id="quoteDiscountDisplay">-$0.00</strong>
|
||||
</p>
|
||||
<p class="mb-1 text-warning d-none" id="rushFeeRow">
|
||||
<i class="bi bi-lightning-fill me-1"></i>Rush Fee: <strong id="rushFeeDisplay">$0.00</strong>
|
||||
</p>
|
||||
<p class="mb-1 d-none" id="shopSuppliesRow">Shop Supplies (<span id="shopSuppliesPercentDisplay">0</span>%): <strong id="shopSuppliesDisplay">$0.00</strong></p>
|
||||
<p class="mb-1 d-none" id="subtotalRow">Subtotal: <strong id="subtotalDisplay">$0.00</strong></p>
|
||||
<p class="mb-1 d-none" id="taxRow">Tax (<span id="taxPercentDisplay">0</span>%): <strong id="taxDisplay">$0.00</strong></p>
|
||||
<hr class="my-2 d-none" id="pricingDivider" />
|
||||
<h5 class="mb-0 d-none" id="totalRow">Total: <strong id="totalDisplay" class="text-primary">$0.00</strong></h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 6: Quote Photos -->
|
||||
@{
|
||||
var editAllPhotos = ViewBag.QuotePhotos as List<PowderCoating.Core.Entities.QuotePhoto> ?? new List<PowderCoating.Core.Entities.QuotePhoto>();
|
||||
bool editCanUpload = ViewBag.CanUploadQuotePhoto is bool eb && eb;
|
||||
int editPhotoUsed = ViewBag.QuotePhotoUsed is int eu ? eu : 0;
|
||||
int editPhotoMax = ViewBag.QuotePhotoMax is int em ? em : -1;
|
||||
}
|
||||
@if (editPhotoMax != 0)
|
||||
{
|
||||
<div class="card mb-4" id="quotePhotosCard">
|
||||
<div class="card-header bg-white d-flex align-items-center justify-content-between">
|
||||
<h5 class="mb-0"><i class="bi bi-images me-2 text-secondary"></i>Photos <span class="badge bg-secondary ms-1" id="photoCount">@editAllPhotos.Count</span></h5>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
@if (editPhotoMax > 0)
|
||||
{
|
||||
<small class="text-muted">@editPhotoUsed / @editPhotoMax</small>
|
||||
}
|
||||
@if (editCanUpload)
|
||||
{
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" onclick="document.getElementById('editPhotoFileInput').click()">
|
||||
<i class="bi bi-cloud-upload me-1"></i>Upload Photo
|
||||
</button>
|
||||
<input type="file" id="editPhotoFileInput" accept="image/*" class="d-none" multiple>
|
||||
}
|
||||
else if (editPhotoMax > 0 && editPhotoUsed >= editPhotoMax)
|
||||
{
|
||||
<span class="badge bg-warning text-dark">Limit reached</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (!editAllPhotos.Any())
|
||||
{
|
||||
<p class="text-muted small mb-0" id="noPhotosMsg">No photos yet. Upload photos to attach them to this quote.</p>
|
||||
}
|
||||
<div class="row g-3" id="editPhotoGrid">
|
||||
@foreach (var photo in editAllPhotos)
|
||||
{
|
||||
<div class="col-sm-6 col-md-4 col-lg-3 photo-item" id="photo-@photo.Id">
|
||||
<div class="card h-100 position-relative">
|
||||
@if (photo.IsAiAnalysisPhoto)
|
||||
{
|
||||
<span class="badge bg-secondary position-absolute top-0 start-0 m-1" style="z-index:1;font-size:.65rem;">AI</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="btn btn-sm btn-danger position-absolute top-0 end-0 m-1 p-0 px-1 delete-photo-btn"
|
||||
style="z-index:1;font-size:.7rem;" data-photo-id="@photo.Id" title="Delete">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
}
|
||||
<a href="@Url.Action("Photo", "Quotes", new { id = photo.Id })" target="_blank" title="View full size">
|
||||
<img src="@Url.Action("Photo", "Quotes", new { id = photo.Id })"
|
||||
class="card-img-top" style="height:160px;object-fit:cover;"
|
||||
alt="@photo.FileName" loading="lazy">
|
||||
</a>
|
||||
<div class="card-body p-2">
|
||||
<p class="card-text small text-muted text-truncate mb-0" title="@photo.FileName">@photo.FileName</p>
|
||||
<p class="card-text text-muted mb-0" style="font-size:.7rem;">@photo.CreatedAt.ToString("MMM d, yyyy")</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div id="editPhotoUploadProgress" class="d-none mt-2">
|
||||
<div class="progress"><div class="progress-bar progress-bar-striped progress-bar-animated" style="width:100%"></div></div>
|
||||
<small class="text-muted">Uploading…</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body d-flex align-items-center justify-content-end flex-wrap gap-3">
|
||||
<div class="form-check form-switch mb-0">
|
||||
<input class="form-check-input" type="checkbox" role="switch"
|
||||
id="SendEmailToCustomer" name="SendEmailToCustomer" value="true" />
|
||||
<label class="form-check-label fw-semibold" for="SendEmailToCustomer">
|
||||
<i class="bi bi-envelope me-1"></i>Send updated quote via email
|
||||
</label>
|
||||
</div>
|
||||
<a asp-action="Details" asp-controller="Quotes" asp-route-id="@Model.Id" class="btn btn-outline-secondary btn-lg">
|
||||
<i class="bi bi-x-circle me-1"></i>Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary btn-lg" id="submitBtn">
|
||||
<i class="bi bi-check-circle me-1"></i>Update Quote
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Surface Area Calculator Modal -->
|
||||
<div class="modal fade" id="sqFtCalculatorModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-calculator me-2"></i>Surface Area Calculator <small class="text-muted">(per item)</small></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Shape</label>
|
||||
<select id="calcShape" class="form-select" onchange="toggleShapeInputs()">
|
||||
<option value="rectangle">Rectangle / Square</option>
|
||||
<option value="cylinder">Cylinder (Tube)</option>
|
||||
<option value="circle">Circle (Flat)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="rectangleInputs">
|
||||
<div class="row g-2">
|
||||
<div class="col-6"><label class="form-label">Length (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
||||
<input type="number" id="rectLength" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
||||
<div class="col-6"><label class="form-label">Width (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
||||
<input type="number" id="rectWidth" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
||||
</div>
|
||||
<small class="text-muted">Formula: L × W ÷ @(ViewBag.UseMetric == true ? "10,000" : "144")</small>
|
||||
</div>
|
||||
<div id="cylinderInputs" style="display:none">
|
||||
<div class="row g-2">
|
||||
<div class="col-6"><label class="form-label">Diameter (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
||||
<input type="number" id="cylDiameter" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
||||
<div class="col-6"><label class="form-label">Height (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
||||
<input type="number" id="cylHeight" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="circleInputs" style="display:none">
|
||||
<label class="form-label">Diameter (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
||||
<input type="number" id="circDiameter" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()">
|
||||
</div>
|
||||
<hr />
|
||||
<div class="alert alert-info mb-0"><strong>Result:</strong> <span id="calcResult">0.00</span> @ViewBag.AreaUnit</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="useSqFtResult()">
|
||||
<i class="bi bi-check-circle me-1"></i>Use This Value
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========================= ITEM WIZARD MODAL ========================= -->
|
||||
<div class="modal fade" id="itemWizardModal" tabindex="-1" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="d-flex flex-column">
|
||||
<h5 class="modal-title mb-0" id="wizardTitle">Add Item</h5>
|
||||
<div class="text-muted small mb-1" id="wizardStepTitle">Choose Item Type</div>
|
||||
<!-- Step progress -->
|
||||
<div class="d-flex align-items-center gap-2" id="wizardStepIndicator">
|
||||
<span class="wizard-step-dot active" data-step="1" title="Item Type"></span>
|
||||
<div class="wizard-step-line"></div>
|
||||
<span class="wizard-step-dot" data-step="2" title="Item Details"></span>
|
||||
<div class="wizard-step-line" id="step2Line"></div>
|
||||
<span class="wizard-step-dot" data-step="3" title="Coating Layers" id="step3Dot"></span>
|
||||
<div class="wizard-step-line" id="step3Line"></div>
|
||||
<span class="wizard-step-dot" data-step="4" title="Prep Services" id="step4Dot"></span>
|
||||
<span class="text-muted small ms-2" id="wizardStepLabel">Step 1 of 4</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close ms-auto" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="wizardBody" style="min-height: 300px;">
|
||||
<!-- Content injected by JS -->
|
||||
</div>
|
||||
<div class="modal-footer justify-content-between">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary d-none" id="btnWizardBack" onclick="wizardBack()">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" id="btnWizardNext" onclick="wizardNext()">
|
||||
Next <i class="bi bi-arrow-right ms-1"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-success d-none" id="btnWizardSave" onclick="wizardSave()">
|
||||
<i class="bi bi-check-lg me-1"></i>Add Item
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Embedded data for JS -->
|
||||
@if (ViewBag.InventoryCoatings != null)
|
||||
{
|
||||
<script id="inventoryPowdersData" type="application/json">
|
||||
@Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.InventoryCoatings))
|
||||
</script>
|
||||
}
|
||||
@if (ViewBag.CatalogItems != null)
|
||||
{
|
||||
<script id="catalogItemsData" type="application/json">
|
||||
@Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.CatalogItems))
|
||||
</script>
|
||||
}
|
||||
<script id="merchandiseItemsData" type="application/json">
|
||||
@Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.MerchandiseItems ?? new List<object>()))
|
||||
</script>
|
||||
<script id="vendorsData" type="application/json">
|
||||
@Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.Vendors ?? new List<object>()))
|
||||
</script>
|
||||
<script id="prepServicesData" type="application/json">
|
||||
@Html.Raw(Json.Serialize(((List<PrepService>)(ViewBag.PrepServices ?? new List<PrepService>())).Select(p => new { id = p.Id, name = p.ServiceName, description = p.Description, requiresBlastSetup = p.RequiresBlastSetup })))
|
||||
</script>
|
||||
<script id="blastSetupsData" type="application/json">
|
||||
@Html.Raw(Json.Serialize(ViewBag.BlastSetups ?? new List<object>()))
|
||||
</script>
|
||||
|
||||
<!-- Existing items — always populated on Edit -->
|
||||
<script id="existingItemsData" type="application/json">
|
||||
@Html.Raw(System.Text.Json.JsonSerializer.Serialize((Model.QuoteItems ?? new List<PowderCoating.Application.DTOs.Quote.CreateQuoteItemDto>()).Select((item, i) => new {
|
||||
description = item.Description,
|
||||
quantity = item.Quantity,
|
||||
surfaceAreaSqFt = item.SurfaceAreaSqFt,
|
||||
estimatedMinutes = item.EstimatedMinutes,
|
||||
catalogItemId = item.CatalogItemId,
|
||||
manualUnitPrice = item.ManualUnitPrice,
|
||||
powderCostOverride = item.PowderCostOverride,
|
||||
isGenericItem = item.IsGenericItem,
|
||||
isLaborItem = item.IsLaborItem,
|
||||
isAiItem = item.IsAiItem,
|
||||
includePrepCost = item.IncludePrepCost,
|
||||
complexity = item.Complexity,
|
||||
aiTags = item.AiTags,
|
||||
aiPredictionId = item.AiPredictionId,
|
||||
requiresSandblasting = item.RequiresSandblasting,
|
||||
requiresMasking = item.RequiresMasking,
|
||||
notes = item.Notes,
|
||||
prepServices = item.PrepServices.Select(ps => new {
|
||||
prepServiceId = ps.PrepServiceId,
|
||||
estimatedMinutes = ps.EstimatedMinutes,
|
||||
blastSetupId = ps.BlastSetupId
|
||||
}),
|
||||
coats = item.Coats.Select(c => new {
|
||||
coatName = c.CoatName,
|
||||
sequence = c.Sequence,
|
||||
inventoryItemId = c.InventoryItemId,
|
||||
colorName = c.ColorName,
|
||||
vendorId = c.VendorId,
|
||||
colorCode = c.ColorCode,
|
||||
finish = c.Finish,
|
||||
coverageSqFtPerLb = c.CoverageSqFtPerLb,
|
||||
transferEfficiency = c.TransferEfficiency,
|
||||
powderCostPerLb = c.PowderCostPerLb,
|
||||
powderToOrder = c.PowderToOrder,
|
||||
notes = c.Notes
|
||||
})
|
||||
})))
|
||||
</script>
|
||||
|
||||
<script id="quoteMetaData" type="application/json">
|
||||
{
|
||||
"quoteId": @Model.Id,
|
||||
"customerId": @Json.Serialize(Model.CustomerId),
|
||||
"taxPercent": @Model.TaxPercent,
|
||||
"companyTaxPercent": @((ViewBag.CompanyTaxPercent ?? Model.TaxPercent).ToString()),
|
||||
"taxExemptCustomerIds": @Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.CustomerTaxExemptIds ?? new System.Collections.Generic.HashSet<int>())),
|
||||
"discountType": @Json.Serialize(Model.DiscountType),
|
||||
"discountValue": @Model.DiscountValue,
|
||||
"isRushJob": @Json.Serialize(Model.IsRushJob),
|
||||
"ovenCostId": @Json.Serialize(Model.OvenCostId),
|
||||
"areaUnit": @Json.Serialize((string?)ViewBag.AreaUnit),
|
||||
"useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)),
|
||||
"pricingUrl": "@Url.Action("CalculatePricing", "Quotes")",
|
||||
"aiUploadUrl": "@Url.Action("UploadAiPhoto", "Quotes")",
|
||||
"aiAnalyzeUrl": "@Url.Action("AiAnalyzeItem", "Quotes")",
|
||||
"aiPhotoQuotesEnabled": @Json.Serialize((bool)(ViewBag.AiPhotoQuotesEnabled ?? true)),
|
||||
"itemsFieldPrefix": "QuoteItems",
|
||||
"aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")"
|
||||
}
|
||||
</script>
|
||||
|
||||
@section Styles {
|
||||
<style>
|
||||
.wizard-step-dot {
|
||||
width: 22px; height: 22px; border-radius: 50%;
|
||||
background: #dee2e6; display: inline-block; cursor: default;
|
||||
border: 2px solid #dee2e6; transition: all .2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wizard-step-dot.active { background: #0d6efd; border-color: #0d6efd; }
|
||||
.wizard-step-dot.done { background: #198754; border-color: #198754; }
|
||||
.wizard-step-dot.skip { background: #adb5bd; border-color: #adb5bd; }
|
||||
.wizard-step-line { flex: 1; height: 2px; background: #dee2e6; min-width: 30px; }
|
||||
.item-type-card {
|
||||
border: 2px solid #dee2e6; border-radius: .75rem; padding: 1.25rem 1rem;
|
||||
cursor: pointer; transition: all .15s; text-align: center;
|
||||
background: #fff; user-select: none;
|
||||
}
|
||||
.item-type-card:hover { border-color: #86b7fe; background: #f0f6ff; }
|
||||
.item-type-card.selected { border-color: #0d6efd; background: #eef3ff; }
|
||||
.item-type-card .item-type-icon { font-size: 2rem; margin-bottom: .5rem; }
|
||||
[data-bs-theme="dark"] .item-type-card { background: var(--bs-tertiary-bg); border-color: var(--bs-border-color); color: var(--bs-body-color); }
|
||||
[data-bs-theme="dark"] .item-type-card:hover { border-color: #86b7fe; background: var(--bs-secondary-bg); }
|
||||
[data-bs-theme="dark"] .item-type-card.selected { border-color: #0d6efd; background: #1a2a4a; }
|
||||
.catalog-list-item { cursor: pointer; border-bottom: 1px solid var(--bs-border-color); font-size: .9rem; transition: background .1s; }
|
||||
.catalog-list-item:last-child { border-bottom: none; }
|
||||
.catalog-list-item:hover { background: var(--bs-tertiary-bg); }
|
||||
.catalog-list-item.selected { background: #eef3ff; color: #0d6efd; font-weight: 600; }
|
||||
[data-bs-theme="dark"] .catalog-list-item.selected { background: #1a2a4a; color: #86b7fe; }
|
||||
.quote-item-card {
|
||||
border: 1px solid #dee2e6; border-radius: .5rem;
|
||||
padding: .75rem 1rem; margin-bottom: .5rem;
|
||||
background: #fafafa;
|
||||
}
|
||||
.quote-item-card .item-badge { font-size: .7rem; }
|
||||
.coat-row { border: 1px solid #dee2e6; border-radius: .5rem; padding: .75rem; margin-bottom: .5rem; }
|
||||
[data-bs-theme="dark"] .quote-item-card { background: var(--bs-tertiary-bg); border-color: var(--bs-border-color); color: var(--bs-body-color); }
|
||||
[data-bs-theme="dark"] .coat-row { background: var(--bs-tertiary-bg); border-color: var(--bs-border-color); }
|
||||
</style>
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
|
||||
<script src="~/js/item-wizard.js?v=@DateTime.Now.Ticks"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
initTagInput('quoteTags', 'quoteTagsContainer');
|
||||
});
|
||||
|
||||
// Discount type toggle
|
||||
function onDiscountTypeChange() {
|
||||
const type = document.getElementById('discountTypeSelect').value;
|
||||
const show = type !== 'None';
|
||||
document.getElementById('discountValueSection').style.display = show ? 'block' : 'none';
|
||||
document.getElementById('discountReasonSection').style.display = show ? 'block' : 'none';
|
||||
document.getElementById('hideDiscountSection').style.display = show ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Surface area calculator
|
||||
let _sqFtTargetInput = null;
|
||||
function openSqFtCalculator(inputId) {
|
||||
_sqFtTargetInput = inputId;
|
||||
document.getElementById('rectLength').value = 0;
|
||||
document.getElementById('rectWidth').value = 0;
|
||||
document.getElementById('calcResult').textContent = '0.00';
|
||||
new bootstrap.Modal(document.getElementById('sqFtCalculatorModal')).show();
|
||||
}
|
||||
function toggleShapeInputs() {
|
||||
const shape = document.getElementById('calcShape').value;
|
||||
document.getElementById('rectangleInputs').style.display = shape === 'rectangle' ? 'block' : 'none';
|
||||
document.getElementById('cylinderInputs').style.display = shape === 'cylinder' ? 'block' : 'none';
|
||||
document.getElementById('circleInputs').style.display = shape === 'circle' ? 'block' : 'none';
|
||||
calculateSqFt();
|
||||
}
|
||||
function calculateSqFt() {
|
||||
const useMetric = @Json.Serialize((bool)(ViewBag.UseMetric ?? false));
|
||||
const divisor = useMetric ? 10000 : 144;
|
||||
const shape = document.getElementById('calcShape').value;
|
||||
let result = 0;
|
||||
if (shape === 'rectangle') {
|
||||
const l = parseFloat(document.getElementById('rectLength').value) || 0;
|
||||
const w = parseFloat(document.getElementById('rectWidth').value) || 0;
|
||||
result = (l * w) / divisor;
|
||||
} else if (shape === 'cylinder') {
|
||||
const d = parseFloat(document.getElementById('cylDiameter').value) || 0;
|
||||
const h = parseFloat(document.getElementById('cylHeight').value) || 0;
|
||||
const r = d / 2;
|
||||
result = (2 * Math.PI * r * r + 2 * Math.PI * r * h) / divisor;
|
||||
} else {
|
||||
const d = parseFloat(document.getElementById('circDiameter').value) || 0;
|
||||
const r = d / 2;
|
||||
result = (Math.PI * r * r) / divisor;
|
||||
}
|
||||
document.getElementById('calcResult').textContent = result.toFixed(4);
|
||||
}
|
||||
function useSqFtResult() {
|
||||
const val = document.getElementById('calcResult').textContent;
|
||||
if (_sqFtTargetInput) {
|
||||
const el = document.getElementById(_sqFtTargetInput) || document.querySelector(`[name="${_sqFtTargetInput}"]`);
|
||||
if (el) { el.value = parseFloat(val).toFixed(2); el.dispatchEvent(new Event('change')); }
|
||||
}
|
||||
bootstrap.Modal.getInstance(document.getElementById('sqFtCalculatorModal'))?.hide();
|
||||
}
|
||||
|
||||
// Form submit guard
|
||||
document.getElementById('quoteForm').addEventListener('submit', function(e) {
|
||||
if (typeof quoteItems === 'undefined' || quoteItems.length === 0) {
|
||||
e.preventDefault();
|
||||
alert('Please add at least one item before submitting.');
|
||||
}
|
||||
});
|
||||
|
||||
// Quote photo direct upload (Edit page — quoteId is known)
|
||||
(function () {
|
||||
const quoteId = @Model.Id;
|
||||
const uploadUrl = '@Url.Action("UploadQuotePhoto", "Quotes")';
|
||||
const deleteUrl = '@Url.Action("DeleteQuotePhoto", "Quotes")';
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
|
||||
const fileInput = document.getElementById('editPhotoFileInput');
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', () => {
|
||||
for (const file of fileInput.files) uploadPhoto(file);
|
||||
fileInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadPhoto(file) {
|
||||
const progress = document.getElementById('editPhotoUploadProgress');
|
||||
progress?.classList.remove('d-none');
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('quoteId', quoteId);
|
||||
fd.append('file', file);
|
||||
fd.append('__RequestVerificationToken', token);
|
||||
|
||||
const resp = await fetch(uploadUrl, { method: 'POST', body: fd });
|
||||
const data = await resp.json();
|
||||
progress?.classList.add('d-none');
|
||||
|
||||
if (!data.success) { alert(data.error || 'Upload failed.'); return; }
|
||||
|
||||
document.getElementById('noPhotosMsg')?.remove();
|
||||
const grid = document.getElementById('editPhotoGrid');
|
||||
const col = document.createElement('div');
|
||||
col.className = 'col-sm-6 col-md-4 col-lg-3 photo-item';
|
||||
col.id = 'photo-' + data.id;
|
||||
col.innerHTML = `
|
||||
<div class="card h-100 position-relative">
|
||||
<button type="button" class="btn btn-sm btn-danger position-absolute top-0 end-0 m-1 p-0 px-1 delete-photo-btn"
|
||||
style="z-index:1;font-size:.7rem;" data-photo-id="${data.id}" title="Delete">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
<a href="${data.url}" target="_blank" title="View full size">
|
||||
<img src="${data.url}" class="card-img-top" style="height:160px;object-fit:cover;" loading="lazy">
|
||||
</a>
|
||||
<div class="card-body p-2">
|
||||
<p class="card-text small text-muted text-truncate mb-0">${data.fileName}</p>
|
||||
<p class="card-text text-muted mb-0" style="font-size:.7rem;">Just now</p>
|
||||
</div>
|
||||
</div>`;
|
||||
grid.appendChild(col);
|
||||
updateCount(1);
|
||||
}
|
||||
|
||||
document.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('.delete-photo-btn');
|
||||
if (!btn) return;
|
||||
if (!confirm('Delete this photo?')) return;
|
||||
const photoId = btn.dataset.photoId;
|
||||
const fd = new FormData();
|
||||
fd.append('id', photoId);
|
||||
fd.append('__RequestVerificationToken', token);
|
||||
const resp = await fetch(deleteUrl, { method: 'POST', body: fd });
|
||||
const data = await resp.json();
|
||||
if (!data.success) { alert(data.error || 'Delete failed.'); return; }
|
||||
document.getElementById('photo-' + photoId)?.remove();
|
||||
updateCount(-1);
|
||||
});
|
||||
|
||||
function updateCount(delta) {
|
||||
const badge = document.getElementById('photoCount');
|
||||
if (badge) badge.textContent = parseInt(badge.textContent || '0') + delta;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
}
|
||||
@@ -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-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>
|
||||
@@ -0,0 +1,60 @@
|
||||
@model (int ItemIndex, List<PowderCoating.Application.DTOs.Quote.CreateQuoteItemPrepServiceDto> SelectedServices)
|
||||
|
||||
@{
|
||||
var itemIndex = Model.ItemIndex;
|
||||
var selectedServices = Model.SelectedServices ?? new List<PowderCoating.Application.DTOs.Quote.CreateQuoteItemPrepServiceDto>();
|
||||
var prepServices = ViewBag.PrepServices as List<PowderCoating.Core.Entities.PrepService> ?? new List<PowderCoating.Core.Entities.PrepService>();
|
||||
}
|
||||
|
||||
@if (prepServices.Any())
|
||||
{
|
||||
<div class="mb-3" data-prep-services-block="@itemIndex">
|
||||
<label class="form-label fw-semibold">
|
||||
<i class="bi bi-tools me-2"></i>Preparation Services
|
||||
</label>
|
||||
<small class="text-muted mt-1 d-block">
|
||||
<i class="bi bi-info-circle me-1"></i>Please select all services needed for this item and enter the estimated amount of time they will take to accomplish.
|
||||
</small>
|
||||
<div class="row g-2">
|
||||
@foreach (var service in prepServices)
|
||||
{
|
||||
var existingEntry = selectedServices.FirstOrDefault(s => s.PrepServiceId == service.Id);
|
||||
var isChecked = existingEntry != null;
|
||||
var existingMinutes = existingEntry?.EstimatedMinutes ?? 0;
|
||||
<div class="col-sm-6 col-md-4 col-lg-3">
|
||||
<label class="prep-service-card h-100 @(isChecked ? "selected" : "")"
|
||||
for="itemPrepService_@(itemIndex)_@(service.Id)">
|
||||
<div class="d-flex justify-content-between align-items-start gap-2">
|
||||
<span class="fw-semibold small">@service.ServiceName</span>
|
||||
<input class="form-check-input flex-shrink-0 mt-0"
|
||||
type="checkbox"
|
||||
id="itemPrepService_@(itemIndex)_@(service.Id)"
|
||||
data-service-id="@service.Id"
|
||||
data-item-index="@itemIndex"
|
||||
@(isChecked ? "checked" : "")
|
||||
onchange="toggleItemPrepService(@itemIndex, @service.Id, this.checked, this)">
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(service.Description))
|
||||
{
|
||||
<small class="text-muted mt-1 d-block">@service.Description</small>
|
||||
}
|
||||
<div id="prepTimeContainer_@(itemIndex)_@(service.Id)"
|
||||
style="display: @(isChecked ? "block" : "none"); margin-top: 6px;">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="number"
|
||||
id="prepTime_@(itemIndex)_@(service.Id)"
|
||||
class="form-control form-control-sm"
|
||||
placeholder="Min"
|
||||
min="1" max="10000" step="1"
|
||||
value="@(isChecked && existingMinutes > 0 ? existingMinutes.ToString() : "")"
|
||||
onclick="event.stopPropagation()"
|
||||
onchange="triggerPricingRecalculation()" />
|
||||
<span class="input-group-text">min</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
Reference in New Issue
Block a user