8aae30765f
Setup Wizard: reduced from 10 steps to 5 (Company Info → QB Migration →
Pricing Defaults → Named Ovens → Notifications). Removed Doc Numbering,
Job Settings, Payment Terms, Pricing Tiers, and Team Members steps — these
all have sensible defaults and are accessible any time in Company Settings.
Wizard now completes in ~5 minutes instead of 15–20.
Dashboard progress widget (new): "Get the most out of your shop" checklist
appears for Company Admins after wizard completion. Tracks six post-setup
activation tasks with dynamic progress badge, motivating subtitle copy,
collapsed-state persistence via localStorage, and a full completion state
("Your shop is fully set up 🎉") that replaces the checklist at 100%.
The next recommended step is highlighted with a solid CTA button and a
subtle blue row tint. Completed steps show encouraging green subtext instead
of just "Done". Widget disappears from controller when AllDone would have
caused a silent vanish — now renders the completion state instead.
Guided activation (Daily Board): rewrote the BoardIntroStep callout to lead
with "This is your shop in real time" and a plain-English description of the
board's purpose. Added a separate InstructionText field to
GuidedActivationCalloutViewModel so the "Move this job to the next stage"
action prompt renders as a distinct bold line with an arrow icon rather than
being buried in the body copy. After the stage change, the confirmation
callout now reads "Nice — your workflow just updated" to reinforce what just
happened before prompting the invoice step.
All copy passes the "shop owner, not SaaS" test: no technical jargon,
benefit-driven descriptions, natural language throughout.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2257 lines
141 KiB
Plaintext
2257 lines
141 KiB
Plaintext
@model PowderCoating.Application.DTOs.Quote.QuoteDto
|
||
|
||
@{
|
||
ViewData["Title"] = $"Quote {Model.QuoteNumber}";
|
||
ViewData["PageIcon"] = "bi-file-text";
|
||
var guidedActivationCallout = ViewBag.GuidedActivationCallout as PowderCoating.Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel;
|
||
var guidedActivationMode = ViewBag.GuidedActivationMode as string;
|
||
}
|
||
|
||
<div class="container-fluid mt-4">
|
||
<div class="row mb-4">
|
||
<div class="col-12 col-md-6 mb-3 mb-md-0">
|
||
<p class="text-muted mb-0">
|
||
<span class="badge bg-@Model.StatusColorClass">@Model.StatusDisplayName</span>
|
||
@if (Model.IsExpired)
|
||
{
|
||
<i class="bi bi-exclamation-triangle text-warning ms-2"></i>
|
||
<span class="text-warning">Expired</span>
|
||
}
|
||
<a tabindex="0" class="help-icon ms-1" role="button"
|
||
data-bs-toggle="popover" data-bs-placement="right"
|
||
data-bs-title="Quote Statuses"
|
||
data-bs-content="<strong>Draft</strong> — saved but not yet sent. Edit freely.<br><strong>Sent</strong> — delivered to the customer, awaiting response.<br><strong>Approved</strong> — customer accepted. You can convert this to a Job.<br><strong>Rejected</strong> — customer declined.<br><strong>Expired</strong> — validity period has passed. Edit to extend it.<br><strong>Converted</strong> — a job has been created from this quote.<br><br><a href='/Help/Quotes#quote-statuses' target='_blank'>Learn more →</a>">
|
||
<i class="bi bi-question-circle"></i>
|
||
</a>
|
||
</p>
|
||
</div>
|
||
<div class="col-12 col-md-6 d-flex flex-column flex-sm-row gap-2 justify-content-md-end align-items-stretch align-items-sm-center">
|
||
<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>
|
||
<button onclick="printQuotePdf(@Model.Id)" class="btn btn-info">
|
||
<i class="bi bi-printer me-1"></i>Print
|
||
</button>
|
||
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-secondary">
|
||
<i class="bi bi-pencil me-1"></i>Edit
|
||
</a>
|
||
@if (Model.IsProspect && Model.StatusCode == "APPROVED")
|
||
{
|
||
<a asp-action="ConvertToCustomer" asp-route-id="@Model.Id" class="btn btn-success">
|
||
<i class="bi bi-arrow-right-circle me-1"></i>Convert
|
||
</a>
|
||
}
|
||
<a asp-action="Index" class="btn btn-outline-secondary">
|
||
<i class="bi bi-arrow-left me-1"></i>Back
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
@if (guidedActivationCallout?.Show == true)
|
||
{
|
||
<div class="alert alert-primary alert-permanent border-0 shadow-sm d-flex flex-column flex-lg-row gap-3 align-items-lg-center justify-content-between mb-4">
|
||
<div>
|
||
<div class="fw-semibold mb-1">@guidedActivationCallout.Title</div>
|
||
<div>@guidedActivationCallout.Message</div>
|
||
</div>
|
||
<div>
|
||
<form asp-action="ConvertToJob" asp-route-id="@Model.Id" method="post" class="d-inline">
|
||
@Html.AntiForgeryToken()
|
||
<input type="hidden" name="guidedActivation" value="@guidedActivationMode" />
|
||
<button type="submit" class="btn btn-primary">
|
||
@guidedActivationCallout.ActionText
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
<div class="row">
|
||
<!-- Left Column: Quote Information -->
|
||
<div class="col-lg-8">
|
||
<!-- Customer/Prospect Info -->
|
||
<div class="card mb-4">
|
||
<div class="card-header">
|
||
<h5 class="mb-0">
|
||
@if (Model.IsProspect)
|
||
{
|
||
<i class="bi bi-person me-2"></i>@:Prospect/Walk-In Information
|
||
}
|
||
else
|
||
{
|
||
<i class="bi bi-building me-2"></i>@:Customer Information
|
||
}
|
||
</h5>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row">
|
||
@if (Model.IsProspect)
|
||
{
|
||
<div class="col-md-6">
|
||
<p><strong>Company Name:</strong> @(Model.ProspectCompanyName ?? "-")</p>
|
||
<p><strong>Contact Name:</strong> @(Model.ProspectContactName ?? "-")</p>
|
||
<p><strong>Email:</strong> @(Model.ProspectEmail ?? "-")</p>
|
||
<p><strong>Phone:</strong> @(Model.ProspectPhone ?? "-")</p>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<p><strong>Address:</strong> @(Model.ProspectAddress ?? "-")</p>
|
||
<p><strong>City:</strong> @(Model.ProspectCity ?? "-")</p>
|
||
<p><strong>State:</strong> @(Model.ProspectState ?? "-")</p>
|
||
<p><strong>Zip Code:</strong> @(Model.ProspectZipCode ?? "-")</p>
|
||
</div>
|
||
}
|
||
else
|
||
{
|
||
<div class="col-md-12">
|
||
@Html.AntiForgeryToken()
|
||
<strong>Customer:</strong>
|
||
<div data-cc-wrap data-cc-id="@Model.Id"
|
||
data-cc-url="@Url.Action("ChangeCustomer", "Quotes")"
|
||
class="d-inline-block ms-1">
|
||
<select class="form-select form-select-sm cc-select" style="max-width:300px;">
|
||
@foreach (var c in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.CustomerSelectList)
|
||
{
|
||
<option value="@c.Value" selected="@(c.Value == Model.CustomerId.ToString() ? "selected" : null)">@c.Text</option>
|
||
}
|
||
</select>
|
||
<div class="cc-confirm-banner d-none mt-2 p-2 bg-light border rounded d-flex align-items-center gap-2 flex-wrap">
|
||
<span class="cc-confirm-text small fw-semibold"></span>
|
||
<button type="button" class="btn btn-success btn-sm" data-cc-save>Save</button>
|
||
<button type="button" class="btn btn-outline-secondary btn-sm" data-cc-cancel>Cancel</button>
|
||
</div>
|
||
<div class="cc-error text-danger small mt-1 d-none"></div>
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Quote Details -->
|
||
<div class="card mb-4">
|
||
<div class="card-header">
|
||
<h5 class="mb-0">
|
||
<i class="bi bi-info-circle me-2"></i>Quote Details
|
||
</h5>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<p><strong>Quote Date:</strong> @Model.QuoteDate.ToString("MM/dd/yyyy")</p>
|
||
<p><strong>Expiration Date:</strong> @(Model.ExpirationDate?.ToString("MM/dd/yyyy") ?? "-")</p>
|
||
<p>
|
||
<strong>Prepared By:</strong>
|
||
@if (!string.IsNullOrEmpty(Model.PreparedById))
|
||
{
|
||
<img src="@Url.Action("Photo", "Profile", new { id = Model.PreparedById })"
|
||
class="rounded-circle me-1" style="width:24px;height:24px;object-fit:cover;"
|
||
onerror="this.style.display='none'" alt="" />
|
||
}
|
||
@(Model.PreparedByName ?? "-")
|
||
</p>
|
||
<p><strong>Type:</strong> @(Model.IsCommercial ? "Commercial" : "Non-Commercial")</p>
|
||
</div>
|
||
<div class="col-md-6">
|
||
@if (Model.SentDate.HasValue)
|
||
{
|
||
<p><strong>Sent Date:</strong> @Model.SentDate.Value.ToString("MM/dd/yyyy")</p>
|
||
}
|
||
@if (Model.ApprovedDate.HasValue)
|
||
{
|
||
<p><strong>Approved Date:</strong> @Model.ApprovedDate.Value.ToString("MM/dd/yyyy")</p>
|
||
}
|
||
@if (Model.ApprovalTokenUsedAt.HasValue && Model.StatusCode == "REJECTED")
|
||
{
|
||
<p><strong>Declined On:</strong> @Model.ApprovalTokenUsedAt.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd/yyyy h:mm tt")</p>
|
||
}
|
||
@if (!string.IsNullOrEmpty(Model.CustomerPO))
|
||
{
|
||
<p><strong>Customer PO:</strong> @Model.CustomerPO</p>
|
||
}
|
||
</div>
|
||
</div>
|
||
@if (!string.IsNullOrEmpty(Model.Description))
|
||
{
|
||
<hr />
|
||
<p><strong>Description:</strong></p>
|
||
<p>@Model.Description</p>
|
||
}
|
||
@if (!string.IsNullOrEmpty(Model.Terms))
|
||
{
|
||
<hr />
|
||
<p><strong>Terms & Conditions:</strong></p>
|
||
<p class="text-muted">@Model.Terms</p>
|
||
}
|
||
@if (!string.IsNullOrEmpty(Model.Notes))
|
||
{
|
||
<hr />
|
||
<p><strong>Internal Notes:</strong></p>
|
||
<p class="text-muted">@Model.Notes</p>
|
||
}
|
||
@if (!string.IsNullOrWhiteSpace(Model.Tags))
|
||
{
|
||
<hr />
|
||
<div class="mb-3">
|
||
<label class="form-label fw-semibold">Tags</label>
|
||
<div>
|
||
@foreach (var tag in Model.Tags.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(t => t.Trim()).Where(t => !string.IsNullOrWhiteSpace(t)))
|
||
{
|
||
<span class="badge rounded-pill bg-info text-dark me-1 mb-1">@tag</span>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
@if (!string.IsNullOrEmpty(Model.DeclineReason))
|
||
{
|
||
<hr />
|
||
<div class="alert alert-danger alert-permanent mb-0 d-flex align-items-start gap-2">
|
||
<i class="bi bi-x-circle-fill fs-5 flex-shrink-0 mt-1"></i>
|
||
<div>
|
||
<strong>Customer Declined</strong>
|
||
<p class="mb-0 mt-1">@Model.DeclineReason</p>
|
||
</div>
|
||
</div>
|
||
}
|
||
@if (Model.PrepServices != null && Model.PrepServices.Any())
|
||
{
|
||
<hr />
|
||
<p><strong><i class="bi bi-tools me-2"></i>Preparation Services:</strong></p>
|
||
<div class="d-flex flex-wrap gap-2">
|
||
@foreach (var service in Model.PrepServices)
|
||
{
|
||
<span class="badge bg-success bg-opacity-10 text-success border border-success px-3 py-2">
|
||
<i class="bi bi-check-circle-fill me-1"></i>@service.ServiceName
|
||
</span>
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Quote Items -->
|
||
<div class="card mb-4">
|
||
<div class="card-header">
|
||
<h5 class="mb-0">
|
||
<i class="bi bi-list-ul me-2"></i>Quote Items
|
||
</h5>
|
||
</div>
|
||
<div class="card-body">
|
||
@if (Model.QuoteItems != null && Model.QuoteItems.Any())
|
||
{
|
||
var catalogItems = Model.QuoteItems.Where(i => i.CatalogItemId.HasValue).ToList();
|
||
var calculatedItems = Model.QuoteItems.Where(i => !i.CatalogItemId.HasValue).ToList();
|
||
|
||
@* Catalog Items Section *@
|
||
@if (catalogItems.Any())
|
||
{
|
||
<div class="mb-4 screen-only-items">
|
||
<h6 class="text-primary mb-3">
|
||
<i class="bi bi-bag-check me-2"></i>Catalog Products
|
||
</h6>
|
||
<div class="table-responsive">
|
||
<table class="table table-hover table-sm">
|
||
<thead class="table-primary">
|
||
<tr>
|
||
<th>Product</th>
|
||
<th>Qty</th>
|
||
<th>Unit Price</th>
|
||
<th>Total</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
@foreach (var item in catalogItems)
|
||
{
|
||
<tr>
|
||
<td>
|
||
@{
|
||
var displayDescription = item.Description == "Product Item" || string.IsNullOrWhiteSpace(item.Description)
|
||
? (item.CatalogItemName ?? "Catalog Item")
|
||
: item.Description;
|
||
}
|
||
<strong>@displayDescription</strong>
|
||
@if (item.CatalogItemId.HasValue &&
|
||
item.Description != "Product Item" &&
|
||
!string.IsNullOrWhiteSpace(item.Description))
|
||
{
|
||
<br />
|
||
<small class="text-muted">
|
||
<i class="bi bi-tag"></i> @item.CatalogItemName
|
||
</small>
|
||
}
|
||
|
||
@* Display coating layers *@
|
||
@if (item.Coats != null && item.Coats.Any())
|
||
{
|
||
<br />
|
||
<small class="text-muted">
|
||
<i class="bi bi-paint-bucket me-1"></i><strong>Coating Layers:</strong>
|
||
</small>
|
||
@foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
|
||
{
|
||
<br />
|
||
<small class="ms-3">
|
||
• <strong>@coat.CoatName</strong>
|
||
@if (!string.IsNullOrEmpty(coat.ColorName))
|
||
{
|
||
<text> - @coat.ColorName</text>
|
||
@if (!string.IsNullOrEmpty(coat.VendorName))
|
||
{
|
||
<text> (@coat.VendorName)</text>
|
||
}
|
||
}
|
||
@if (!string.IsNullOrEmpty(coat.ColorCode))
|
||
{
|
||
<text> (@coat.ColorCode)</text>
|
||
}
|
||
@if (coat.InventoryItemId.HasValue && !string.IsNullOrEmpty(coat.InventoryItemName))
|
||
{
|
||
<span class="badge bg-primary" style="font-size: 0.7em;">
|
||
<i class="bi bi-box"></i> @coat.InventoryItemName
|
||
</span>
|
||
}
|
||
@if (coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
|
||
{
|
||
@if (coat.InventoryItemId.HasValue)
|
||
{
|
||
<span class="badge bg-info" style="font-size: 0.7em;">
|
||
Need: @coat.PowderToOrder lbs
|
||
</span>
|
||
}
|
||
else
|
||
{
|
||
<span class="badge bg-warning text-dark" style="font-size: 0.7em;" title="Custom powder — must be purchased before coating">
|
||
<i class="bi bi-cart me-1"></i>ORDER: @coat.PowderToOrder?.ToString("0.##") lbs
|
||
</span>
|
||
}
|
||
}
|
||
@if (!string.IsNullOrEmpty(coat.Notes))
|
||
{
|
||
<br />
|
||
<span class="ms-4 text-muted fst-italic">@coat.Notes</span>
|
||
}
|
||
</small>
|
||
}
|
||
}
|
||
|
||
@if (!string.IsNullOrEmpty(item.Notes))
|
||
{
|
||
<br />
|
||
<small class="text-muted"><i class="bi bi-sticky"></i> <strong>Notes:</strong> @item.Notes</small>
|
||
}
|
||
</td>
|
||
<td>@item.Quantity</td>
|
||
<td>@item.UnitPrice.ToString("C")</td>
|
||
<td><strong>@item.TotalPrice.ToString("C")</strong></td>
|
||
</tr>
|
||
}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
@* Calculated Items Section *@
|
||
@if (calculatedItems.Any())
|
||
{
|
||
<div class="mb-4 screen-only-items">
|
||
<h6 class="text-success mb-3">
|
||
<i class="bi bi-calculator me-2"></i>Custom Work
|
||
</h6>
|
||
<div class="table-responsive">
|
||
<table class="table table-hover table-sm">
|
||
<thead class="table-success">
|
||
<tr>
|
||
<th>Description</th>
|
||
<th>Qty</th>
|
||
<th>Surface Area</th>
|
||
<th>Est. Minutes</th>
|
||
<th>Coating Needed</th>
|
||
<th>Unit Price</th>
|
||
<th>Total</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
@foreach (var item in calculatedItems)
|
||
{
|
||
// Calculate powder needed for this item
|
||
var coverageRate = 30m; // sq ft per lb (default estimate)
|
||
var transferEff = 0.65m; // 65% efficiency (default estimate)
|
||
var totalSqFt = item.SurfaceAreaSqFt * item.Quantity;
|
||
var powderOnPart = totalSqFt / coverageRate;
|
||
var totalPowderNeeded = powderOnPart / transferEff;
|
||
<tr>
|
||
<td>
|
||
<strong>@item.Description</strong>
|
||
|
||
@* Display coating layers *@
|
||
@if (item.Coats != null && item.Coats.Any())
|
||
{
|
||
<br />
|
||
<small class="text-muted">
|
||
<i class="bi bi-paint-bucket me-1"></i><strong>Coating Layers:</strong>
|
||
</small>
|
||
@foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
|
||
{
|
||
<br />
|
||
<small class="ms-3">
|
||
• <strong>@coat.CoatName</strong>
|
||
@if (!string.IsNullOrEmpty(coat.ColorName))
|
||
{
|
||
<text> - @coat.ColorName</text>
|
||
@if (!string.IsNullOrEmpty(coat.VendorName))
|
||
{
|
||
<text> (@coat.VendorName)</text>
|
||
}
|
||
}
|
||
@if (!string.IsNullOrEmpty(coat.ColorCode))
|
||
{
|
||
<text> (@coat.ColorCode)</text>
|
||
}
|
||
@if (coat.InventoryItemId.HasValue && !string.IsNullOrEmpty(coat.InventoryItemName))
|
||
{
|
||
<span class="badge bg-primary" style="font-size: 0.7em;">
|
||
<i class="bi bi-box"></i> @coat.InventoryItemName
|
||
</span>
|
||
}
|
||
@if (coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
|
||
{
|
||
@if (coat.InventoryItemId.HasValue)
|
||
{
|
||
<span class="badge bg-info" style="font-size: 0.7em;">
|
||
Need: @coat.PowderToOrder lbs
|
||
</span>
|
||
}
|
||
else
|
||
{
|
||
<span class="badge bg-warning text-dark" style="font-size: 0.7em;" title="Custom powder — must be purchased before coating">
|
||
<i class="bi bi-cart me-1"></i>ORDER: @coat.PowderToOrder?.ToString("0.##") lbs
|
||
</span>
|
||
}
|
||
}
|
||
@if (!string.IsNullOrEmpty(coat.Notes))
|
||
{
|
||
<br />
|
||
<span class="ms-4 text-muted fst-italic">@coat.Notes</span>
|
||
}
|
||
</small>
|
||
}
|
||
}
|
||
|
||
@if (item.PrepServices != null && item.PrepServices.Any())
|
||
{
|
||
<br />
|
||
<small class="text-muted">
|
||
<i class="bi bi-tools me-1"></i><strong>Prep Services:</strong>
|
||
</small>
|
||
@foreach (var ps in item.PrepServices)
|
||
{
|
||
<br />
|
||
<small class="ms-3">
|
||
• <strong>@(ps.PrepServiceName ?? $"Service #{ps.PrepServiceId}")</strong>
|
||
<span class="text-muted">— @ps.EstimatedMinutes min</span>
|
||
</small>
|
||
}
|
||
}
|
||
|
||
@if (!string.IsNullOrEmpty(item.Notes))
|
||
{
|
||
<br />
|
||
<small class="text-muted"><i class="bi bi-sticky"></i> <strong>Notes:</strong> @item.Notes</small>
|
||
}
|
||
</td>
|
||
<td>@item.Quantity</td>
|
||
<td>
|
||
@item.SurfaceAreaSqFt.ToString("F2") @ViewBag.AreaUnit
|
||
<br /><small class="text-muted">per item</small>
|
||
</td>
|
||
<td>
|
||
@item.EstimatedMinutes min
|
||
<br /><small class="text-muted">per item</small>
|
||
</td>
|
||
<td>
|
||
<strong class="text-success">@totalPowderNeeded.ToString("F2") lbs</strong>
|
||
<br /><small class="text-muted">total batch</small>
|
||
</td>
|
||
<td>@item.UnitPrice.ToString("C")</td>
|
||
<td><strong>@item.TotalPrice.ToString("C")</strong></td>
|
||
</tr>
|
||
}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
}
|
||
}
|
||
else
|
||
{
|
||
<div class="text-center text-muted py-4">
|
||
<i class="bi bi-inbox" style="font-size: 3rem;"></i>
|
||
<p class="mt-2">No items in this quote.</p>
|
||
</div>
|
||
}
|
||
|
||
<!-- Mobile Card View for Quote Items -->
|
||
<div class="d-lg-none">
|
||
@if (Model.QuoteItems != null && Model.QuoteItems.Any())
|
||
{
|
||
var mobileCatalogItems = Model.QuoteItems.Where(i => i.CatalogItemId.HasValue).ToList();
|
||
var mobileCalculatedItems = Model.QuoteItems.Where(i => !i.CatalogItemId.HasValue).ToList();
|
||
|
||
@* Catalog Items Mobile Section *@
|
||
@if (mobileCatalogItems.Any())
|
||
{
|
||
<h6 class="text-primary mb-3 mt-3">
|
||
<i class="bi bi-bag-check me-2"></i>Catalog Products
|
||
</h6>
|
||
<div class="mobile-card-list">
|
||
@foreach (var item in mobileCatalogItems)
|
||
{
|
||
<div class="mobile-data-card">
|
||
<div class="mobile-card-header">
|
||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #4a90e2 0%, #357abd 100%);">
|
||
<i class="bi bi-bag-check"></i>
|
||
</div>
|
||
<div class="mobile-card-title">
|
||
@{
|
||
var displayDesc = item.Description == "Product Item" || string.IsNullOrWhiteSpace(item.Description)
|
||
? (item.CatalogItemName ?? "Catalog Item")
|
||
: item.Description;
|
||
}
|
||
<h6>@displayDesc</h6>
|
||
@if (item.CatalogItemId.HasValue &&
|
||
item.Description != "Product Item" &&
|
||
!string.IsNullOrWhiteSpace(item.Description))
|
||
{
|
||
<small class="text-muted">
|
||
<i class="bi bi-tag"></i> @item.CatalogItemName
|
||
</small>
|
||
}
|
||
</div>
|
||
</div>
|
||
<div class="mobile-card-body">
|
||
<div class="mobile-card-row">
|
||
<span class="mobile-card-label">Quantity</span>
|
||
<span class="mobile-card-value">@item.Quantity</span>
|
||
</div>
|
||
<div class="mobile-card-row">
|
||
<span class="mobile-card-label">Unit Price</span>
|
||
<span class="mobile-card-value">@item.UnitPrice.ToString("C")</span>
|
||
</div>
|
||
<div class="mobile-card-row">
|
||
<span class="mobile-card-label">Total Price</span>
|
||
<span class="mobile-card-value fw-semibold text-primary">@item.TotalPrice.ToString("C")</span>
|
||
</div>
|
||
@if (!string.IsNullOrEmpty(item.Notes))
|
||
{
|
||
<div class="mobile-card-row" style="border-bottom: none;">
|
||
<span class="mobile-card-label">Notes</span>
|
||
<span class="mobile-card-value text-muted" style="white-space: normal; text-align: right;">@item.Notes</span>
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
|
||
@* Calculated Items Mobile Section *@
|
||
@if (mobileCalculatedItems.Any())
|
||
{
|
||
<h6 class="text-success mb-3 mt-4">
|
||
<i class="bi bi-calculator me-2"></i>Custom Work
|
||
</h6>
|
||
<div class="mobile-card-list">
|
||
@foreach (var item in mobileCalculatedItems)
|
||
{
|
||
// Calculate powder needed for mobile view
|
||
var mobileCoverageRate = 30m;
|
||
var mobileTransferEff = 0.65m;
|
||
var mobileTotalSqFt = item.SurfaceAreaSqFt * item.Quantity;
|
||
var mobilePowderOnPart = mobileTotalSqFt / mobileCoverageRate;
|
||
var mobileTotalPowderNeeded = mobilePowderOnPart / mobileTransferEff;
|
||
<div class="mobile-data-card">
|
||
<div class="mobile-card-header">
|
||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #52c41a 0%, #389e0d 100%);">
|
||
<i class="bi bi-calculator"></i>
|
||
</div>
|
||
<div class="mobile-card-title">
|
||
<h6>@item.Description</h6>
|
||
<small>@(item.Coats.FirstOrDefault()?.ColorName ?? "No color specified")</small>
|
||
</div>
|
||
</div>
|
||
<div class="mobile-card-body">
|
||
<div class="mobile-card-row">
|
||
<span class="mobile-card-label">Quantity</span>
|
||
<span class="mobile-card-value">@item.Quantity</span>
|
||
</div>
|
||
<div class="mobile-card-row">
|
||
<span class="mobile-card-label">Surface Area</span>
|
||
<span class="mobile-card-value">@item.SurfaceAreaSqFt.ToString("F2") @ViewBag.AreaUnit <small class="text-muted">(per item)</small></span>
|
||
</div>
|
||
<div class="mobile-card-row">
|
||
<span class="mobile-card-label">Est. Time</span>
|
||
<span class="mobile-card-value">@item.EstimatedMinutes min <small class="text-muted">(per item)</small></span>
|
||
</div>
|
||
<div class="mobile-card-row">
|
||
<span class="mobile-card-label">Coating Needed</span>
|
||
<span class="mobile-card-value fw-semibold text-success">@mobileTotalPowderNeeded.ToString("F2") lbs <small class="text-muted">(batch)</small></span>
|
||
</div>
|
||
@if (item.Coats != null && item.Coats.Any())
|
||
{
|
||
<div class="mobile-card-row">
|
||
<span class="mobile-card-label">Coating Layers</span>
|
||
<span class="mobile-card-value">
|
||
@foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
|
||
{
|
||
<div class="mb-1">
|
||
<strong>@coat.CoatName</strong>
|
||
@if (!string.IsNullOrEmpty(coat.ColorName))
|
||
{
|
||
<text> - @coat.ColorName</text>
|
||
}
|
||
@if (!string.IsNullOrEmpty(coat.ColorCode))
|
||
{
|
||
<text> (@coat.ColorCode)</text>
|
||
}
|
||
@if (coat.InventoryItemId.HasValue && !string.IsNullOrEmpty(coat.InventoryItemName))
|
||
{
|
||
<br />
|
||
<span class="badge bg-primary" style="font-size: 0.7em;">
|
||
<i class="bi bi-box"></i> @coat.InventoryItemName
|
||
</span>
|
||
}
|
||
@if (coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
|
||
{
|
||
<br />
|
||
@if (coat.InventoryItemId.HasValue)
|
||
{
|
||
<span class="badge bg-info" style="font-size: 0.7em;">
|
||
Need: @coat.PowderToOrder lbs
|
||
</span>
|
||
}
|
||
else
|
||
{
|
||
<span class="badge bg-success" style="font-size: 0.7em;">
|
||
<i class="bi bi-cart-plus"></i> Order: @coat.PowderToOrder lbs
|
||
</span>
|
||
}
|
||
}
|
||
</div>
|
||
}
|
||
</span>
|
||
</div>
|
||
}
|
||
@if (item.PrepServices != null && item.PrepServices.Any())
|
||
{
|
||
<div class="mobile-card-row">
|
||
<span class="mobile-card-label">Prep Services</span>
|
||
<span class="mobile-card-value">
|
||
@foreach (var ps in item.PrepServices)
|
||
{
|
||
<div class="mb-1">
|
||
<strong>@(ps.PrepServiceName ?? $"Service #{ps.PrepServiceId}")</strong>
|
||
<span class="text-muted">— @ps.EstimatedMinutes min</span>
|
||
</div>
|
||
}
|
||
</span>
|
||
</div>
|
||
}
|
||
<div class="mobile-card-row">
|
||
<span class="mobile-card-label">Unit Price</span>
|
||
<span class="mobile-card-value">@item.UnitPrice.ToString("C")</span>
|
||
</div>
|
||
<div class="mobile-card-row">
|
||
<span class="mobile-card-label">Total Price</span>
|
||
<span class="mobile-card-value fw-semibold text-primary">@item.TotalPrice.ToString("C")</span>
|
||
</div>
|
||
@if (!string.IsNullOrEmpty(item.Notes))
|
||
{
|
||
<div class="mobile-card-row" style="border-bottom: none;">
|
||
<span class="mobile-card-label">Notes</span>
|
||
<span class="mobile-card-value text-muted" style="white-space: normal; text-align: right;">@item.Notes</span>
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Quote Photos Section -->
|
||
@{
|
||
var allPhotos = ViewBag.QuotePhotos as List<PowderCoating.Core.Entities.QuotePhoto> ?? new List<PowderCoating.Core.Entities.QuotePhoto>();
|
||
bool canUpload = ViewBag.CanUploadQuotePhoto is bool b && b;
|
||
int photoUsed = ViewBag.QuotePhotoUsed is int u ? u : 0;
|
||
int photoMax = ViewBag.QuotePhotoMax is int m ? m : -1;
|
||
}
|
||
@if (photoMax != 0)
|
||
{
|
||
<div class="card border-0 shadow-sm mt-4" id="quotePhotosCard">
|
||
<div class="card-header bg-white border-0 py-3 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">@allPhotos.Count</span>
|
||
</h5>
|
||
<div class="d-flex align-items-center gap-2">
|
||
@if (photoMax > 0)
|
||
{
|
||
<small class="text-muted">@photoUsed / @photoMax</small>
|
||
}
|
||
@if (canUpload)
|
||
{
|
||
<button type="button" class="btn btn-sm btn-outline-primary" onclick="document.getElementById('quotePhotoFileInput').click()">
|
||
<i class="bi bi-cloud-upload me-1"></i>Upload Photo
|
||
</button>
|
||
<input type="file" id="quotePhotoFileInput" accept="image/*" class="d-none" multiple>
|
||
}
|
||
else if (photoMax > 0 && photoUsed >= photoMax)
|
||
{
|
||
<span class="badge bg-warning text-dark">Limit reached</span>
|
||
}
|
||
</div>
|
||
</div>
|
||
<div class="card-body" id="quotePhotosBody">
|
||
@if (!allPhotos.Any())
|
||
{
|
||
<p class="text-muted small mb-0" id="noPhotosMsg">No photos uploaded yet.</p>
|
||
}
|
||
<div class="row g-2" id="photoGrid">
|
||
@for (int pi = 0; pi < allPhotos.Count; pi++)
|
||
{
|
||
var photo = allPhotos[pi];
|
||
<div class="col-sm-6 col-md-4 photo-item" id="photo-@photo.Id">
|
||
<div class="position-relative" style="cursor:pointer;" onclick="qpGallery.open(@pi)">
|
||
@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>
|
||
}
|
||
<img src="@Url.Action("Photo", "Quotes", new { id = photo.Id })"
|
||
class="img-fluid rounded" style="width:100%;height:140px;object-fit:cover;"
|
||
alt="@photo.FileName" loading="lazy">
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
<div id="photoUploadProgress" 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>
|
||
|
||
<!-- Quote Photo Lightbox Modal -->
|
||
<div class="modal fade" id="qpModal" tabindex="-1">
|
||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title" id="qpTitle">Quote Photo</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body text-center p-3">
|
||
<img id="qpImg" class="img-fluid rounded mb-3" style="max-height:60vh;">
|
||
<div class="d-flex justify-content-between align-items-center">
|
||
<button type="button" class="btn btn-outline-primary" id="qpPrev" onclick="qpGallery.navigate(-1)">
|
||
<i class="bi bi-chevron-left"></i> Previous
|
||
</button>
|
||
<span id="qpPosition" class="text-muted small"></span>
|
||
<button type="button" class="btn btn-outline-primary" id="qpNext" onclick="qpGallery.navigate(1)">
|
||
Next <i class="bi bi-chevron-right"></i>
|
||
</button>
|
||
</div>
|
||
<div class="mt-2 small text-muted" id="qpDate"></div>
|
||
<div class="mt-1 small text-muted text-truncate" id="qpFileName"></div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-danger" id="qpDeleteBtn" onclick="qpGallery.deletePhoto()">
|
||
<i class="bi bi-trash me-1"></i>Delete
|
||
</button>
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const qpGallery = (() => {
|
||
const photos = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(allPhotos.Select(p => new {
|
||
id = p.Id,
|
||
url = Url.Action("Photo", "Quotes", new { id = p.Id }),
|
||
fileName = p.FileName,
|
||
date = p.CreatedAt.ToString("MMM d, yyyy"),
|
||
isAi = p.IsAiAnalysisPhoto
|
||
})));
|
||
|
||
let currentIndex = 0;
|
||
const quoteId = @Model.Id;
|
||
const uploadUrl = '@Url.Action("UploadQuotePhoto", "Quotes")';
|
||
const deleteUrl = '@Url.Action("DeleteQuotePhoto", "Quotes")';
|
||
const token = () => document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||
|
||
function render() {
|
||
const p = photos[currentIndex];
|
||
document.getElementById('qpImg').src = p.url;
|
||
document.getElementById('qpTitle').textContent = p.isAi ? 'AI Analysis Photo' : 'Quote Photo';
|
||
document.getElementById('qpDate').textContent = p.date;
|
||
document.getElementById('qpFileName').textContent = p.fileName;
|
||
document.getElementById('qpPosition').textContent = `Photo ${currentIndex + 1} of ${photos.length}`;
|
||
document.getElementById('qpDeleteBtn').style.display = p.isAi ? 'none' : '';
|
||
document.getElementById('qpPrev').disabled = photos.length <= 1;
|
||
document.getElementById('qpNext').disabled = photos.length <= 1;
|
||
}
|
||
|
||
function open(index) {
|
||
currentIndex = index;
|
||
render();
|
||
bootstrap.Modal.getOrCreateInstance(document.getElementById('qpModal')).show();
|
||
}
|
||
|
||
function navigate(dir) {
|
||
currentIndex = (currentIndex + dir + photos.length) % photos.length;
|
||
render();
|
||
}
|
||
|
||
async function deletePhoto() {
|
||
if (!confirm('Delete this photo?')) return;
|
||
const p = photos[currentIndex];
|
||
const fd = new FormData();
|
||
fd.append('id', p.id);
|
||
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; }
|
||
|
||
photos.splice(currentIndex, 1);
|
||
document.getElementById('photo-' + p.id)?.remove();
|
||
updateCount(-1);
|
||
|
||
if (photos.length === 0) {
|
||
bootstrap.Modal.getInstance(document.getElementById('qpModal')).hide();
|
||
if (!document.getElementById('noPhotosMsg')) {
|
||
const msg = document.createElement('p');
|
||
msg.id = 'noPhotosMsg';
|
||
msg.className = 'text-muted small mb-0';
|
||
msg.textContent = 'No photos uploaded yet.';
|
||
document.getElementById('quotePhotosBody').prepend(msg);
|
||
}
|
||
} else {
|
||
if (currentIndex >= photos.length) currentIndex = photos.length - 1;
|
||
render();
|
||
}
|
||
}
|
||
|
||
const fileInput = document.getElementById('quotePhotoFileInput');
|
||
if (fileInput) {
|
||
fileInput.addEventListener('change', () => {
|
||
for (const file of fileInput.files) uploadPhoto(file);
|
||
fileInput.value = '';
|
||
});
|
||
}
|
||
|
||
async function uploadPhoto(file) {
|
||
const progress = document.getElementById('photoUploadProgress');
|
||
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; }
|
||
|
||
const newIndex = photos.length;
|
||
photos.push({ id: data.id, url: data.url, fileName: data.fileName, date: 'Just now', isAi: false });
|
||
|
||
document.getElementById('noPhotosMsg')?.remove();
|
||
const grid = document.getElementById('photoGrid');
|
||
const col = document.createElement('div');
|
||
col.className = 'col-sm-6 col-md-4 photo-item';
|
||
col.id = 'photo-' + data.id;
|
||
col.innerHTML = `
|
||
<div class="position-relative" style="cursor:pointer;" onclick="qpGallery.open(${newIndex})">
|
||
<img src="${data.url}" class="img-fluid rounded" style="width:100%;height:140px;object-fit:cover;" loading="lazy">
|
||
</div>`;
|
||
grid.appendChild(col);
|
||
updateCount(1);
|
||
}
|
||
|
||
function updateCount(delta) {
|
||
const badge = document.getElementById('photoCount');
|
||
if (badge) badge.textContent = parseInt(badge.textContent || '0') + delta;
|
||
}
|
||
|
||
return { open, navigate, deletePhoto };
|
||
})();
|
||
</script>
|
||
}
|
||
</div>
|
||
|
||
<!-- Right Column: Pricing Summary -->
|
||
<div class="col-lg-4">
|
||
<div class="card mb-4">
|
||
<div class="card-header bg-primary text-white">
|
||
<h5 class="mb-0">
|
||
<i class="bi bi-cash-stack me-2"></i>Pricing Summary
|
||
</h5>
|
||
</div>
|
||
<div class="card-body">
|
||
@if (!string.IsNullOrWhiteSpace(Model.OvenLabel))
|
||
{
|
||
<div class="d-flex align-items-center mb-3 p-2 bg-body-secondary rounded">
|
||
<i class="bi bi-thermometer-half text-warning me-2"></i>
|
||
<div>
|
||
<small class="text-muted d-block">Oven</small>
|
||
<span class="fw-semibold">@Model.OvenLabel</span>
|
||
</div>
|
||
</div>
|
||
}
|
||
<div class="d-flex justify-content-between mb-2">
|
||
<span>Items Subtotal:</span>
|
||
<strong>@Model.PricingBreakdown.ItemsSubtotal.ToString("C")</strong>
|
||
</div>
|
||
|
||
@if (Model.PricingBreakdown.OvenBatchCost > 0)
|
||
{
|
||
<div class="d-flex justify-content-between mb-2">
|
||
<span>
|
||
<i class="bi bi-fire me-1"></i>Oven
|
||
(@Model.PricingBreakdown.OvenBatches batch@(Model.PricingBreakdown.OvenBatches != 1 ? "es" : "")
|
||
× @Model.PricingBreakdown.OvenCycleMinutes min):
|
||
</span>
|
||
<strong>@Model.PricingBreakdown.OvenBatchCost.ToString("C")</strong>
|
||
</div>
|
||
}
|
||
|
||
@if (Model.PricingBreakdown.FacilityOverheadCost > 0)
|
||
{
|
||
<div class="d-flex justify-content-between mb-2">
|
||
<span><i class="bi bi-building me-1"></i>Facility Overhead (@Model.PricingBreakdown.FacilityOverheadRatePerHour.ToString("C2")/hr):</span>
|
||
<strong>@Model.PricingBreakdown.FacilityOverheadCost.ToString("C")</strong>
|
||
</div>
|
||
}
|
||
|
||
@if (Model.PricingBreakdown.ShopSuppliesAmount > 0)
|
||
{
|
||
<div class="d-flex justify-content-between mb-2">
|
||
<span>Shop Supplies (@Model.PricingBreakdown.ShopSuppliesPercent%):</span>
|
||
<strong>@Model.PricingBreakdown.ShopSuppliesAmount.ToString("C")</strong>
|
||
</div>
|
||
}
|
||
|
||
<div class="d-flex justify-content-between mb-2">
|
||
<span>Subtotal:</span>
|
||
<strong>@Model.PricingBreakdown.SubtotalBeforeDiscount.ToString("C")</strong>
|
||
</div>
|
||
|
||
@if (Model.PricingBreakdown.DiscountAmount > 0)
|
||
{
|
||
<div class="d-flex justify-content-between mb-2 text-success">
|
||
<span>
|
||
@if (Model.DiscountType == "Percentage")
|
||
{
|
||
<span>Discount (@Model.DiscountValue% Off):</span>
|
||
}
|
||
else if (Model.DiscountType == "FixedAmount")
|
||
{
|
||
<span>Discount (@Model.DiscountValue.ToString("C") Off):</span>
|
||
}
|
||
else
|
||
{
|
||
<span>Discount (@Model.PricingBreakdown.DiscountPercent.ToString("F1")%):</span>
|
||
}
|
||
</span>
|
||
<strong>-@Model.PricingBreakdown.DiscountAmount.ToString("C")</strong>
|
||
</div>
|
||
@if (!string.IsNullOrWhiteSpace(Model.DiscountReason))
|
||
{
|
||
<div class="mb-2">
|
||
<small class="text-muted fst-italic">
|
||
<i class="bi bi-info-circle me-1"></i>Reason: @Model.DiscountReason
|
||
</small>
|
||
</div>
|
||
}
|
||
@if (Model.HideDiscountFromCustomer)
|
||
{
|
||
<div class="mb-2">
|
||
<small class="text-warning"><i class="bi bi-eye-slash me-1"></i>Discount hidden from customer on PDFs and approval portal</small>
|
||
</div>
|
||
}
|
||
}
|
||
|
||
@if (Model.IsRushJob && Model.PricingBreakdown.RushFee > 0)
|
||
{
|
||
<div class="d-flex justify-content-between mb-2 text-warning">
|
||
<span>
|
||
<i class="bi bi-lightning-fill me-1"></i>Rush Job Fee:
|
||
</span>
|
||
<strong>@Model.PricingBreakdown.RushFee.ToString("C")</strong>
|
||
</div>
|
||
}
|
||
|
||
@if (Model.PricingBreakdown.TaxAmount > 0)
|
||
{
|
||
<div class="d-flex justify-content-between mb-2">
|
||
<span>Tax (@Model.PricingBreakdown.TaxPercent.ToString("G29")%):</span>
|
||
<strong>@Model.PricingBreakdown.TaxAmount.ToString("C")</strong>
|
||
</div>
|
||
}
|
||
|
||
<hr />
|
||
|
||
<div class="d-flex justify-content-between mb-0">
|
||
<h5>Total:</h5>
|
||
<h5 class="text-primary"><strong>@Model.Total.ToString("C")</strong></h5>
|
||
</div>
|
||
|
||
@if (Model.PricingBreakdown.DiscountAmount > 0)
|
||
{
|
||
<div class="d-flex justify-content-center mt-2">
|
||
<span class="badge bg-success-subtle text-success-emphasis fs-6 px-3 py-2">
|
||
<i class="bi bi-piggy-bank me-1"></i>You Save: @Model.PricingBreakdown.DiscountAmount.ToString("C")
|
||
</span>
|
||
</div>
|
||
}
|
||
|
||
@if (Model.PricingBreakdown != null)
|
||
{
|
||
<hr />
|
||
<button class="btn btn-sm btn-outline-secondary w-100" type="button" data-bs-toggle="collapse" data-bs-target="#pricingBreakdown">
|
||
<i class="bi bi-calculator me-1"></i>Pricing Breakdown
|
||
</button>
|
||
<div class="collapse mt-3" id="pricingBreakdown">
|
||
@{
|
||
var pb = Model.PricingBreakdown;
|
||
var dbgCosts = ViewBag.OperatingCosts as PowderCoating.Core.Entities.CompanyOperatingCosts;
|
||
var directCosts = pb.MaterialCosts + pb.LaborCosts + pb.EquipmentCosts;
|
||
var hasCostBreakdown = pb.MaterialCosts > 0 || pb.LaborCosts > 0 || pb.EquipmentCosts > 0;
|
||
var allCatalog = Model.QuoteItems != null && Model.QuoteItems.All(i => i.CatalogItemId.HasValue);
|
||
}
|
||
|
||
@* ── SECTION 1: Item Cost Breakdown ─────────────────────── *@
|
||
<div class="mb-3">
|
||
<div class="text-uppercase text-muted fw-semibold small mb-2" style="letter-spacing:.05em;">
|
||
<i class="bi bi-boxes me-1"></i>Item Costs
|
||
</div>
|
||
@if (hasCostBreakdown)
|
||
{
|
||
<div class="d-flex justify-content-between small mb-1">
|
||
<span class="text-muted">Material (powder + consumables)</span>
|
||
<span>@pb.MaterialCosts.ToString("C")</span>
|
||
</div>
|
||
<div class="d-flex justify-content-between small mb-1">
|
||
<span class="text-muted">Labor</span>
|
||
<span>@pb.LaborCosts.ToString("C")</span>
|
||
</div>
|
||
<div class="d-flex justify-content-between small mb-1">
|
||
<span class="text-muted">Equipment (oven + booth)</span>
|
||
<span>@pb.EquipmentCosts.ToString("C")</span>
|
||
</div>
|
||
<div class="d-flex justify-content-between small border-top pt-1 mt-1">
|
||
<span class="text-muted">Direct costs</span>
|
||
<span>@directCosts.ToString("C")</span>
|
||
</div>
|
||
<div class="d-flex justify-content-between small mb-1">
|
||
<span class="text-muted">Markup (@pb.ProfitPercent.ToString("F0")% baked into item prices)</span>
|
||
<span>@((pb.ItemsSubtotal - directCosts).ToString("C"))</span>
|
||
</div>
|
||
}
|
||
else if (allCatalog)
|
||
{
|
||
<div class="text-muted small fst-italic">All items use fixed catalog pricing — no per-category cost split available.</div>
|
||
}
|
||
else
|
||
{
|
||
<div class="text-muted small fst-italic">Cost breakdown not available for this quote (saved before tracking was added).</div>
|
||
}
|
||
<div class="d-flex justify-content-between small fw-semibold border-top pt-1 mt-1">
|
||
<span>Items subtotal</span>
|
||
<span>@pb.ItemsSubtotal.ToString("C")</span>
|
||
</div>
|
||
</div>
|
||
|
||
@* ── SECTION 2: Quote-Level Additions ───────────────────── *@
|
||
@if (pb.OvenBatchCost > 0 || pb.FacilityOverheadCost > 0 || pb.ShopSuppliesAmount > 0 || pb.OverheadCosts > 0)
|
||
{
|
||
<div class="mb-3">
|
||
<div class="text-uppercase text-muted fw-semibold small mb-2" style="letter-spacing:.05em;">
|
||
<i class="bi bi-plus-circle me-1"></i>Quote-Level Additions
|
||
</div>
|
||
@if (pb.OvenBatchCost > 0)
|
||
{
|
||
<div class="d-flex justify-content-between small mb-1">
|
||
<span class="text-muted">Oven batch (@pb.OvenBatches batch@(pb.OvenBatches != 1 ? "es" : ""), @pb.OvenCycleMinutes min/cycle)</span>
|
||
<span>@pb.OvenBatchCost.ToString("C")</span>
|
||
</div>
|
||
}
|
||
@if (pb.FacilityOverheadCost > 0)
|
||
{
|
||
<div class="d-flex justify-content-between small mb-1">
|
||
<span class="text-muted">Facility overhead (@pb.FacilityOverheadRatePerHour.ToString("C2")/hr × estimated hours)</span>
|
||
<span>@pb.FacilityOverheadCost.ToString("C")</span>
|
||
</div>
|
||
}
|
||
@if (pb.ShopSuppliesAmount > 0)
|
||
{
|
||
<div class="d-flex justify-content-between small mb-1">
|
||
<span class="text-muted">Shop supplies (@pb.ShopSuppliesPercent.ToString("F1")%)</span>
|
||
<span>@pb.ShopSuppliesAmount.ToString("C")</span>
|
||
</div>
|
||
}
|
||
@if (pb.OverheadCosts > 0)
|
||
{
|
||
<div class="d-flex justify-content-between small mb-1">
|
||
<span class="text-muted">Overhead (@pb.OverheadPercent.ToString("F1")%)</span>
|
||
<span>@pb.OverheadCosts.ToString("C")</span>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
|
||
@* ── SECTION 3: Final Calculation ────────────────────────── *@
|
||
<div class="mb-2">
|
||
<div class="text-uppercase text-muted fw-semibold small mb-2" style="letter-spacing:.05em;">
|
||
<i class="bi bi-receipt me-1"></i>Final Calculation
|
||
</div>
|
||
<div class="d-flex justify-content-between small mb-1">
|
||
<span class="text-muted">Subtotal</span>
|
||
<span>@pb.SubtotalBeforeDiscount.ToString("C")</span>
|
||
</div>
|
||
@if (pb.DiscountAmount > 0)
|
||
{
|
||
<div class="d-flex justify-content-between small mb-1 text-success">
|
||
<span>Discount (@pb.DiscountPercent.ToString("F1")%)</span>
|
||
<span>-@pb.DiscountAmount.ToString("C")</span>
|
||
</div>
|
||
<div class="d-flex justify-content-between small mb-1">
|
||
<span class="text-muted">After discount</span>
|
||
<span>@pb.SubtotalAfterDiscount.ToString("C")</span>
|
||
</div>
|
||
}
|
||
@if (pb.RushFee > 0)
|
||
{
|
||
<div class="d-flex justify-content-between small mb-1">
|
||
<span class="text-muted">Rush fee</span>
|
||
<span>@pb.RushFee.ToString("C")</span>
|
||
</div>
|
||
}
|
||
@if (pb.TaxAmount > 0)
|
||
{
|
||
<div class="d-flex justify-content-between small mb-1">
|
||
<span class="text-muted">Tax (@pb.TaxPercent.ToString("G29")%)</span>
|
||
<span>@pb.TaxAmount.ToString("C")</span>
|
||
</div>
|
||
}
|
||
<div class="d-flex justify-content-between fw-bold border-top pt-2 mt-1">
|
||
<span>Total</span>
|
||
<span>@pb.Total.ToString("C")</span>
|
||
</div>
|
||
@{
|
||
var totalDirectCost = pb.MaterialCosts + pb.LaborCosts + pb.EquipmentCosts + pb.OvenBatchCost + pb.FacilityOverheadCost + pb.ShopSuppliesAmount;
|
||
var grossProfit = pb.Total - totalDirectCost;
|
||
var effectiveMargin = pb.Total > 0 ? (grossProfit / pb.Total * 100m) : 0m;
|
||
var pricingModeLabel = dbgCosts?.PricingMode == PowderCoating.Core.Enums.PricingMode.MarginOnTotalCost ? "margin" : "markup";
|
||
}
|
||
@if (totalDirectCost > 0)
|
||
{
|
||
<div class="d-flex justify-content-between small mt-2 pt-1 border-top @(effectiveMargin < 10 ? "text-danger" : effectiveMargin < 20 ? "text-warning" : "text-success")">
|
||
<span>Effective gross margin</span>
|
||
<span class="fw-semibold">@effectiveMargin.ToString("F1")%</span>
|
||
</div>
|
||
@if (Model.DiscountAmount > 0)
|
||
{
|
||
var priceBeforeDiscount = pb.Total + pb.DiscountAmount;
|
||
var marginBeforeDiscount = priceBeforeDiscount > 0 ? ((priceBeforeDiscount - totalDirectCost) / priceBeforeDiscount * 100m) : 0m;
|
||
<div class="small text-muted">
|
||
Without discount: @marginBeforeDiscount.ToString("F1")% — discount cost you @(marginBeforeDiscount - effectiveMargin).ToString("F1") margin points
|
||
</div>
|
||
}
|
||
}
|
||
</div>
|
||
|
||
@* ── SECTION 4: Per-Item Cost Breakdown ─────────────────── *@
|
||
@{
|
||
var hasItemCostData = Model.QuoteItems != null && Model.QuoteItems.Any(i =>
|
||
i.ItemMaterialCost > 0 || i.ItemLaborCost > 0 || i.ItemEquipmentCost > 0);
|
||
var markupPct = dbgCosts?.GeneralMarkupPercentage ?? pb.ProfitPercent;
|
||
var laborRate = dbgCosts?.StandardLaborRate ?? 0m;
|
||
var boothRate = dbgCosts?.CoatingBoothCostPerHour ?? 0m;
|
||
}
|
||
@if (Model.QuoteItems != null && Model.QuoteItems.Any())
|
||
{
|
||
<hr class="my-3" />
|
||
<button class="btn btn-sm btn-link text-muted p-0 mb-2" type="button" data-bs-toggle="collapse" data-bs-target="#itemCostDetail">
|
||
<i class="bi bi-table me-1"></i>Per-Item Cost Detail
|
||
</button>
|
||
<div class="collapse" id="itemCostDetail">
|
||
|
||
@* Customer tier alert *@
|
||
@if (ViewBag.PricingTier != null)
|
||
{
|
||
var tier = ViewBag.PricingTier as PowderCoating.Core.Entities.PricingTier;
|
||
<div class="alert alert-success alert-permanent py-2 px-3 mb-3 small">
|
||
<i class="bi bi-tag me-1"></i>
|
||
<strong>Pricing Tier:</strong> @tier.TierName — @tier.DiscountPercent% discount applied to this customer
|
||
</div>
|
||
}
|
||
|
||
@if (!hasItemCostData)
|
||
{
|
||
<div class="text-muted small fst-italic mb-3">Per-item cost detail not available — re-save this quote to capture the breakdown.</div>
|
||
}
|
||
|
||
@* Per-item breakdown cards *@
|
||
@foreach (var item in Model.QuoteItems)
|
||
{
|
||
var rawPowder = item.Coats.Sum(c => c.CoatMaterialCost);
|
||
var consumables = item.ItemMaterialCost - rawPowder;
|
||
var markupAmt = item.ItemMaterialCost * (markupPct / 100m);
|
||
var coatingLabor = item.Coats.Sum(c => c.CoatLaborCost);
|
||
var prepLabor = item.ItemLaborCost - coatingLabor;
|
||
var prepMinutes = item.PrepServices?.Sum(ps => ps.EstimatedMinutes) ?? 0;
|
||
var boothHours = boothRate > 0 ? item.ItemEquipmentCost / boothRate : 0m;
|
||
var hasDetail = item.ItemMaterialCost > 0 || item.ItemLaborCost > 0 || item.ItemEquipmentCost > 0;
|
||
<div class="border rounded mb-2 small overflow-hidden">
|
||
@* Item header *@
|
||
<div class="d-flex justify-content-between align-items-center px-3 py-2 bg-light">
|
||
<div>
|
||
<span class="fw-semibold">@(item.Description ?? item.CatalogItemName ?? "(no description)")</span>
|
||
@if (item.CatalogItemId.HasValue) { <span class="badge bg-primary ms-1">Catalog</span> }
|
||
@if (item.IsAiItem) { <span class="badge bg-purple ms-1">AI</span> }
|
||
@if (item.SurfaceAreaSqFt > 0 || item.EstimatedMinutes > 0)
|
||
{
|
||
<span class="text-muted ms-2" style="font-size:.8rem;">
|
||
@if (item.SurfaceAreaSqFt > 0) { <text>@item.SurfaceAreaSqFt.ToString("F2") sqft</text> }
|
||
@if (item.SurfaceAreaSqFt > 0 && item.EstimatedMinutes > 0) { <text> · </text> }
|
||
@if (item.EstimatedMinutes > 0) { <text>@item.EstimatedMinutes min</text> }
|
||
</span>
|
||
}
|
||
</div>
|
||
<div class="text-end">
|
||
@if (item.Quantity > 1)
|
||
{
|
||
<span class="text-muted me-1">×@item.Quantity.ToString("G29")</span>
|
||
}
|
||
<span class="fw-semibold">@item.TotalPrice.ToString("C")</span>
|
||
</div>
|
||
</div>
|
||
|
||
@if (hasDetail)
|
||
{
|
||
<div class="px-3 py-2">
|
||
<div class="row g-3">
|
||
|
||
@* MATERIAL column *@
|
||
@if (item.ItemMaterialCost > 0 || rawPowder > 0)
|
||
{
|
||
<div class="col-12 col-md-4">
|
||
<div class="text-uppercase text-muted fw-semibold mb-1" style="font-size:.7rem;letter-spacing:.06em;">
|
||
<i class="bi bi-droplet me-1"></i>Material
|
||
</div>
|
||
@if (rawPowder > 0)
|
||
{
|
||
<div class="d-flex justify-content-between">
|
||
<span class="text-muted">Powder (raw)</span>
|
||
<span>@rawPowder.ToString("C")</span>
|
||
</div>
|
||
}
|
||
@if (consumables > 0.001m)
|
||
{
|
||
<div class="d-flex justify-content-between">
|
||
<span class="text-muted">Consumables (+5%)</span>
|
||
<span>@consumables.ToString("C")</span>
|
||
</div>
|
||
}
|
||
@if (markupAmt > 0.001m)
|
||
{
|
||
<div class="d-flex justify-content-between">
|
||
<span class="text-muted">Markup (+@markupPct.ToString("F0")%)</span>
|
||
<span>@markupAmt.ToString("C")</span>
|
||
</div>
|
||
}
|
||
<div class="d-flex justify-content-between fw-semibold border-top mt-1 pt-1">
|
||
<span>Material total</span>
|
||
<span>@((item.ItemMaterialCost + markupAmt).ToString("C"))</span>
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
@* LABOR column *@
|
||
@if (item.ItemLaborCost > 0)
|
||
{
|
||
<div class="col-12 col-md-4">
|
||
<div class="text-uppercase text-muted fw-semibold mb-1" style="font-size:.7rem;letter-spacing:.06em;">
|
||
<i class="bi bi-person-workspace me-1"></i>Labor
|
||
@if (laborRate > 0) { <span class="fw-normal text-muted ms-1">@@ @laborRate.ToString("C0")/hr</span> }
|
||
</div>
|
||
@if (coatingLabor > 0)
|
||
{
|
||
<div class="d-flex justify-content-between">
|
||
<span class="text-muted">
|
||
Coating
|
||
@if (item.EstimatedMinutes > 0) { <span class="ms-1">(@item.EstimatedMinutes min)</span> }
|
||
</span>
|
||
<span>@coatingLabor.ToString("C")</span>
|
||
</div>
|
||
}
|
||
@if (prepLabor > 0.001m)
|
||
{
|
||
<div class="d-flex justify-content-between">
|
||
<span class="text-muted">
|
||
Prep services
|
||
@if (prepMinutes > 0) { <span class="ms-1">(@prepMinutes min)</span> }
|
||
</span>
|
||
<span>@prepLabor.ToString("C")</span>
|
||
</div>
|
||
}
|
||
<div class="d-flex justify-content-between fw-semibold border-top mt-1 pt-1">
|
||
<span>Labor total</span>
|
||
<span>@item.ItemLaborCost.ToString("C")</span>
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
@* EQUIPMENT column *@
|
||
@if (item.ItemEquipmentCost > 0)
|
||
{
|
||
<div class="col-12 col-md-4">
|
||
<div class="text-uppercase text-muted fw-semibold mb-1" style="font-size:.7rem;letter-spacing:.06em;">
|
||
<i class="bi bi-gear me-1"></i>Equipment
|
||
</div>
|
||
<div class="d-flex justify-content-between">
|
||
<span class="text-muted">
|
||
Coating booth
|
||
@if (boothRate > 0 && boothHours > 0) { <text>(@boothHours.ToString("F2") hrs @@ @boothRate.ToString("C0")/hr)</text> }
|
||
</span>
|
||
<span>@item.ItemEquipmentCost.ToString("C")</span>
|
||
</div>
|
||
<div class="d-flex justify-content-between fw-semibold border-top mt-1 pt-1">
|
||
<span>Equipment total</span>
|
||
<span>@item.ItemEquipmentCost.ToString("C")</span>
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
|
||
@* Coat detail rows *@
|
||
@if (item.Coats.Any())
|
||
{
|
||
<div class="mt-2 pt-2 border-top">
|
||
<div class="text-uppercase text-muted fw-semibold mb-1" style="font-size:.7rem;letter-spacing:.06em;">
|
||
<i class="bi bi-layers me-1"></i>Coat Layers
|
||
</div>
|
||
@foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
|
||
{
|
||
<div class="d-flex justify-content-between text-muted ps-2 border-start border-2 mb-1">
|
||
<div>
|
||
@coat.CoatName
|
||
@if (!string.IsNullOrEmpty(coat.ColorName)) { <text> — @coat.ColorName</text> }
|
||
@if (!string.IsNullOrEmpty(coat.ColorCode)) { <text> (@coat.ColorCode)</text> }
|
||
@if (coat.InventoryItemId.HasValue)
|
||
{
|
||
<span class="badge bg-success-subtle text-success-emphasis ms-1">Stock powder</span>
|
||
}
|
||
else if (coat.PowderToOrder > 0)
|
||
{
|
||
<span class="badge bg-warning-subtle text-warning-emphasis ms-1">Order @coat.PowderToOrder?.ToString("0.##") lbs</span>
|
||
}
|
||
</div>
|
||
<div class="text-end text-nowrap ms-3">
|
||
@if (coat.CoatMaterialCost > 0) { <span>@coat.CoatMaterialCost.ToString("C") mat</span> }
|
||
@if (coat.CoatLaborCost > 0) { <span class="ms-2">@coat.CoatLaborCost.ToString("C") labor</span> }
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
|
||
@* Prep service rows *@
|
||
@if (item.PrepServices != null && item.PrepServices.Any())
|
||
{
|
||
<div class="mt-2 pt-2 border-top">
|
||
<div class="text-uppercase text-muted fw-semibold mb-1" style="font-size:.7rem;letter-spacing:.06em;">
|
||
<i class="bi bi-tools me-1"></i>Prep Services Applied
|
||
</div>
|
||
@foreach (var ps in item.PrepServices)
|
||
{
|
||
<div class="d-flex justify-content-between text-muted ps-2 border-start border-2 mb-1">
|
||
<span>@ps.PrepServiceName @if (ps.EstimatedMinutes > 0) { <text>(@ps.EstimatedMinutes min)</text> }</span>
|
||
<span>@if (laborRate > 0 && ps.EstimatedMinutes > 0) { <text>@((ps.EstimatedMinutes / 60m * laborRate).ToString("C"))</text> }</span>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
else if (item.CatalogItemId.HasValue && !item.Coats.Any())
|
||
{
|
||
<div class="px-3 py-2 text-muted fst-italic">Catalog fixed price — no cost split available.</div>
|
||
}
|
||
else if (item.IsAiItem)
|
||
{
|
||
<div class="px-3 py-2 text-muted fst-italic">AI-estimated price — cost breakdown not applicable.</div>
|
||
}
|
||
</div>
|
||
}
|
||
|
||
@* Totals footer *@
|
||
@if (hasItemCostData)
|
||
{
|
||
<div class="d-flex justify-content-between fw-semibold border-top pt-2 mt-1 small">
|
||
<span class="text-muted">All items — material / labor / equipment / total</span>
|
||
<span>@pb.MaterialCosts.ToString("C") / @pb.LaborCosts.ToString("C") / @pb.EquipmentCosts.ToString("C") / @pb.ItemsSubtotal.ToString("C")</span>
|
||
</div>
|
||
}
|
||
|
||
@* Rates reference *@
|
||
@if (dbgCosts != null)
|
||
{
|
||
<details class="mt-3">
|
||
<summary class="small text-muted" style="cursor:pointer;">Active rates used for this calculation</summary>
|
||
<table class="table table-sm table-borderless mt-2 mb-0">
|
||
<tbody class="small text-muted">
|
||
<tr>
|
||
<td class="ps-0">Labor rate</td>
|
||
<td class="text-end pe-0">@dbgCosts.StandardLaborRate.ToString("C2")/hr @if (dbgCosts.StandardLaborRate == 0) { <span class="badge bg-danger">Not Set</span> }</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="ps-0">Coating booth</td>
|
||
<td class="text-end pe-0">@dbgCosts.CoatingBoothCostPerHour.ToString("C2")/hr</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="ps-0">Default powder cost</td>
|
||
<td class="text-end pe-0">@dbgCosts.PowderCoatingCostPerSqFt.ToString("C4")/sqft @if (dbgCosts.PowderCoatingCostPerSqFt == 0) { <span class="badge bg-danger">Not Set</span> }</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="ps-0">Markup</td>
|
||
<td class="text-end pe-0">@dbgCosts.GeneralMarkupPercentage.ToString("F1")% — applied to material costs</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="ps-0">Additional coat charge</td>
|
||
<td class="text-end pe-0">@dbgCosts.AdditionalCoatLaborPercent.ToString("F0")% of first coat price per extra coat</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="ps-0">Consumables surcharge</td>
|
||
<td class="text-end pe-0">5% of raw powder cost</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</details>
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Actions Card -->
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h5 class="mb-0">
|
||
<i class="bi bi-gear me-2"></i>Actions
|
||
</h5>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="d-grid gap-2">
|
||
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-outline-secondary">
|
||
<i class="bi bi-pencil me-1"></i>Edit Quote
|
||
</a>
|
||
@if (Model.StatusCode != "APPROVED" && Model.StatusCode != "CONVERTED")
|
||
{
|
||
<form asp-action="ApproveQuote" asp-route-id="@Model.Id" method="post" id="approveQuoteForm">
|
||
@Html.AntiForgeryToken()
|
||
<div class="form-check form-switch mb-2">
|
||
<input class="form-check-input" type="checkbox" role="switch"
|
||
id="approveQuoteSendEmail" name="sendEmail" value="true"
|
||
@(ViewBag.EmailDefaultOnApprove == true ? "checked" : "") />
|
||
<label class="form-check-label small" for="approveQuoteSendEmail">
|
||
<i class="bi bi-envelope me-1"></i>Notify customer via email
|
||
</label>
|
||
</div>
|
||
<button type="button" class="btn btn-outline-success w-100" data-bs-toggle="modal" data-bs-target="#approveQuoteModal">
|
||
<i class="bi bi-check-circle me-1"></i>Approve Quote
|
||
</button>
|
||
</form>
|
||
}
|
||
<button type="button" class="btn btn-outline-primary" onclick="resendQuote(@Model.Id)">
|
||
<i class="bi bi-envelope-arrow-up me-1"></i>Send Quote to Customer
|
||
</button>
|
||
@if (!Model.ConvertedToJobId.HasValue)
|
||
{
|
||
<form asp-action="ConvertToJob" asp-route-id="@Model.Id" method="post" class="d-inline" id="createJobForm">
|
||
@Html.AntiForgeryToken()
|
||
<input type="hidden" name="guidedActivation" value="@guidedActivationMode" />
|
||
<button type="button" class="btn btn-success w-100" data-bs-toggle="modal" data-bs-target="#createJobModal">
|
||
<i class="bi bi-clipboard-check me-1"></i>Create Job from Quote
|
||
</button>
|
||
</form>
|
||
}
|
||
@if (Model.IsProspect && Model.StatusCode == "APPROVED")
|
||
{
|
||
<a asp-action="ConvertToCustomer" asp-route-id="@Model.Id" class="btn btn-outline-success">
|
||
<i class="bi bi-arrow-right-circle me-1"></i>Convert to Customer Only
|
||
</a>
|
||
}
|
||
@if (Model.ConvertedToJobId.HasValue)
|
||
{
|
||
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@Model.ConvertedToJobId" class="btn btn-outline-info">
|
||
<i class="bi bi-clipboard-check me-1"></i>View Job
|
||
</a>
|
||
}
|
||
<a asp-action="DownloadPdf" asp-route-id="@Model.Id" class="btn btn-primary">
|
||
<i class="bi bi-file-pdf me-1"></i>Download PDF Quote
|
||
</a>
|
||
<button onclick="printQuotePdf(@Model.Id)" class="btn btn-info">
|
||
<i class="bi bi-printer me-1"></i>Print Quote
|
||
</button>
|
||
<button type="button" class="btn btn-outline-secondary" onclick="loadNotifications(@Model.Id)">
|
||
<i class="bi bi-bell me-1"></i>View Notifications Sent
|
||
</button>
|
||
@if (Model.StatusCode != "CONVERTED")
|
||
{
|
||
<a asp-action="Delete" asp-route-id="@Model.Id" class="btn btn-outline-danger">
|
||
<i class="bi bi-trash me-1"></i>Delete Quote
|
||
</a>
|
||
}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Print-only unified items table (hidden on screen, shown when printing) -->
|
||
<div class="print-only-items">
|
||
<div class="card mb-4">
|
||
<div class="card-header">
|
||
<h5 class="mb-0">
|
||
<i class="bi bi-list-ul me-2"></i>Quote Items
|
||
</h5>
|
||
</div>
|
||
<div class="card-body">
|
||
@if (Model.QuoteItems != null && Model.QuoteItems.Any())
|
||
{
|
||
<div class="table-responsive">
|
||
<table class="table table-bordered table-hover table-sm">
|
||
<thead class="table-light">
|
||
<tr>
|
||
<th>Description</th>
|
||
<th style="width: 80px;">Qty</th>
|
||
<th style="width: 200px;">Coats</th>
|
||
<th style="width: 120px;">Total</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
@foreach (var item in Model.QuoteItems.OrderBy(i => i.Id))
|
||
{
|
||
// Build description based on item type
|
||
string description;
|
||
if (item.CatalogItemId.HasValue)
|
||
{
|
||
// For catalog items, show the product name
|
||
description = item.CatalogItemName ?? "Catalog Item";
|
||
// If there's a custom description (not "Product Item"), include it
|
||
if (!string.IsNullOrWhiteSpace(item.Description) && item.Description != "Product Item")
|
||
{
|
||
description = item.Description;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// For calculated items, use the description
|
||
description = item.Description ?? "Custom Item";
|
||
}
|
||
|
||
<tr>
|
||
<td>
|
||
<strong>@description</strong>
|
||
@if (!string.IsNullOrEmpty(item.Notes))
|
||
{
|
||
<br />
|
||
<small class="text-muted">@item.Notes</small>
|
||
}
|
||
</td>
|
||
<td class="text-center">@item.Quantity</td>
|
||
<td>
|
||
@if (item.Coats != null && item.Coats.Any())
|
||
{
|
||
@foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
|
||
{
|
||
<div style="margin-bottom: 4px;">
|
||
<strong>@coat.CoatName</strong>
|
||
@if (!string.IsNullOrEmpty(coat.ColorName))
|
||
{
|
||
<text> - @coat.ColorName</text>
|
||
@if (!string.IsNullOrEmpty(coat.VendorName))
|
||
{
|
||
<text> (@coat.VendorName)</text>
|
||
}
|
||
}
|
||
@if (!string.IsNullOrEmpty(coat.ColorCode))
|
||
{
|
||
<text> (@coat.ColorCode)</text>
|
||
}
|
||
@if (coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
|
||
{
|
||
<br />
|
||
@if (coat.InventoryItemId.HasValue)
|
||
{
|
||
<small>Need: @coat.PowderToOrder lbs</small>
|
||
}
|
||
else
|
||
{
|
||
<small><i class="bi bi-cart-plus"></i> Order: @coat.PowderToOrder lbs</small>
|
||
}
|
||
}
|
||
</div>
|
||
}
|
||
}
|
||
else
|
||
{
|
||
<text>-</text>
|
||
}
|
||
</td>
|
||
<td class="text-end"><strong>@item.TotalPrice.ToString("C")</strong></td>
|
||
</tr>
|
||
}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
}
|
||
else
|
||
{
|
||
<div class="text-center text-muted py-4">
|
||
<p>No items in this quote.</p>
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
<!-- Deposits Section -->
|
||
@{
|
||
var quoteDeposits = ViewBag.Deposits as List<PowderCoating.Core.Entities.Deposit> ?? new List<PowderCoating.Core.Entities.Deposit>();
|
||
var quoteTotalDeposited = quoteDeposits.Sum(d => d.Amount);
|
||
}
|
||
<div class="card border-0 shadow-sm mt-4">
|
||
<div class="card-header bg-body border-0 py-3 d-flex justify-content-between align-items-center">
|
||
<h5 class="mb-0 fw-semibold">
|
||
<i class="bi bi-cash-coin me-2 text-success"></i>Deposits
|
||
@if (quoteDeposits.Any())
|
||
{
|
||
<span class="badge bg-success rounded-pill ms-2">@quoteDeposits.Count</span>
|
||
}
|
||
</h5>
|
||
<div class="d-flex align-items-center gap-2">
|
||
@if (quoteTotalDeposited > 0)
|
||
{
|
||
<span class="fw-semibold text-success">Total: @quoteTotalDeposited.ToString("C")</span>
|
||
}
|
||
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#addDepositModal">
|
||
<i class="bi bi-plus-circle me-1"></i>Record Deposit
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="card-body p-0">
|
||
@if (!quoteDeposits.Any())
|
||
{
|
||
<div class="text-center py-4">
|
||
<i class="bi bi-cash-coin" style="font-size: 2.5rem; opacity: 0.2;"></i>
|
||
<p class="text-muted mt-2 mb-0">No deposits recorded</p>
|
||
<small class="text-muted">Click "Record Deposit" to add a customer deposit</small>
|
||
</div>
|
||
}
|
||
else
|
||
{
|
||
<div class="table-responsive">
|
||
<table class="table table-hover mb-0">
|
||
<thead class="table-light">
|
||
<tr>
|
||
<th>Receipt #</th>
|
||
<th>Date</th>
|
||
<th>Method</th>
|
||
<th>Reference</th>
|
||
<th class="text-end">Amount</th>
|
||
<th>Status</th>
|
||
<th class="text-end">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
@foreach (var dep in quoteDeposits)
|
||
{
|
||
<tr>
|
||
<td class="fw-semibold">@dep.ReceiptNumber</td>
|
||
<td>@dep.ReceivedDate.ToString("MM/dd/yyyy")</td>
|
||
<td>@dep.PaymentMethod.ToString().Replace("CreditDebitCard","Credit/Debit Card").Replace("BankTransferACH","Bank Transfer/ACH").Replace("DigitalPayment","Digital")</td>
|
||
<td class="text-muted small">@dep.Reference</td>
|
||
<td class="text-end fw-semibold text-success">@dep.Amount.ToString("C")</td>
|
||
<td>
|
||
@if (dep.AppliedToInvoiceId.HasValue)
|
||
{
|
||
<span class="badge bg-success">Applied</span>
|
||
}
|
||
else
|
||
{
|
||
<span class="badge bg-warning text-dark">Pending</span>
|
||
}
|
||
</td>
|
||
<td class="text-end">
|
||
<a href="@Url.Action("Receipt", "Deposits", new { id = dep.Id })" class="btn btn-sm btn-outline-secondary" target="_blank" title="Download Receipt">
|
||
<i class="bi bi-file-pdf"></i>
|
||
</a>
|
||
@if (dep.AppliedToInvoiceId == null)
|
||
{
|
||
<button type="button" class="btn btn-sm btn-outline-danger ms-1"
|
||
onclick="deleteDeposit(@dep.Id, '@dep.ReceiptNumber')" title="Delete">
|
||
<i class="bi bi-trash"></i>
|
||
</button>
|
||
}
|
||
</td>
|
||
</tr>
|
||
}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Add Deposit Modal -->
|
||
<div class="modal fade" id="addDepositModal" tabindex="-1" aria-labelledby="addDepositModalLabel" aria-hidden="true">
|
||
<div class="modal-dialog">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title" id="addDepositModalLabel">
|
||
<i class="bi bi-cash-coin me-2 text-success"></i>Record Customer Deposit
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<form id="addDepositForm">
|
||
@Html.AntiForgeryToken()
|
||
<input type="hidden" name="quoteId" value="@Model.Id" />
|
||
<input type="hidden" name="customerId" value="@(Model.CustomerId ?? 0)" />
|
||
<div class="modal-body">
|
||
<div class="mb-3">
|
||
<label class="form-label fw-semibold">Amount <span class="text-danger">*</span></label>
|
||
<div class="input-group">
|
||
<span class="input-group-text">$</span>
|
||
<input type="number" class="form-control" id="depositAmount" name="amount" min="0.01" step="0.01" required placeholder="0.00" />
|
||
</div>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label fw-semibold">Payment Method <span class="text-danger">*</span></label>
|
||
<select class="form-select" id="depositPaymentMethod" name="paymentMethod" required>
|
||
<option value="">-- Select --</option>
|
||
<option value="Cash">Cash</option>
|
||
<option value="Check">Check</option>
|
||
<option value="CreditDebitCard">Credit / Debit Card</option>
|
||
<option value="BankTransferACH">Bank Transfer / ACH</option>
|
||
<option value="DigitalPayment">Digital Payment</option>
|
||
</select>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label fw-semibold">Date Received <span class="text-danger">*</span></label>
|
||
<input type="date" class="form-control" id="depositDate" name="receivedDate" required value="@(DateTime.Today.ToString("yyyy-MM-dd"))" />
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label">Reference (check #, card last 4, etc.)</label>
|
||
<input type="text" class="form-control" id="depositReference" name="reference" maxlength="200" />
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label">Notes</label>
|
||
<textarea class="form-control" id="depositNotes" name="notes" rows="2" maxlength="500"></textarea>
|
||
</div>
|
||
<div id="depositFormError" class="alert alert-danger d-none"></div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||
<button type="submit" class="btn btn-success" id="saveDepositBtn">
|
||
<i class="bi bi-check-circle me-1"></i>Save & Generate Receipt
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Change History Section -->
|
||
@if (ViewBag.ChangeHistory != null && ((List<PowderCoating.Application.DTOs.Quote.QuoteChangeHistoryDto>)ViewBag.ChangeHistory).Any())
|
||
{
|
||
<div class="card border-0 shadow-sm mt-4">
|
||
<div class="card-header bg-white border-0 py-3">
|
||
<h5 class="mb-0">
|
||
<i class="bi bi-clock-history me-2"></i>Change History
|
||
</h5>
|
||
</div>
|
||
<div class="card-body p-0">
|
||
<div class="table-responsive">
|
||
<table class="table table-hover mb-0">
|
||
<thead class="table-light">
|
||
<tr>
|
||
<th style="width: 13%">Date & Time</th>
|
||
<th style="width: 12%">Changed By</th>
|
||
<th style="width: 12%">Field</th>
|
||
<th style="width: 13%">Old Value</th>
|
||
<th style="width: 13%">New Value</th>
|
||
<th>Description</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
@foreach (var change in (List<PowderCoating.Application.DTOs.Quote.QuoteChangeHistoryDto>)ViewBag.ChangeHistory)
|
||
{
|
||
<tr>
|
||
<td>
|
||
<div>@change.ChangedAt.ToString("MM/dd/yyyy")</div>
|
||
<small class="text-muted">@change.ChangedAt.ToString("h:mm tt")</small>
|
||
</td>
|
||
<td>@change.ChangedByName</td>
|
||
<td><strong>@change.FieldName</strong></td>
|
||
<td>
|
||
@if (!string.IsNullOrEmpty(change.OldValue))
|
||
{
|
||
<span class="text-muted">@change.OldValue</span>
|
||
}
|
||
else
|
||
{
|
||
<span class="text-muted fst-italic">None</span>
|
||
}
|
||
</td>
|
||
<td>
|
||
@if (!string.IsNullOrEmpty(change.NewValue))
|
||
{
|
||
<strong>@change.NewValue</strong>
|
||
}
|
||
else
|
||
{
|
||
<span class="text-muted fst-italic">None</span>
|
||
}
|
||
</td>
|
||
<td>
|
||
@if (!string.IsNullOrEmpty(change.ChangeDescription))
|
||
{
|
||
<span class="text-muted small">@change.ChangeDescription</span>
|
||
}
|
||
</td>
|
||
</tr>
|
||
}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
|
||
<!-- Approve Quote Confirmation Modal -->
|
||
<div class="modal fade" id="approveQuoteModal" tabindex="-1" aria-labelledby="approveQuoteModalLabel" aria-hidden="true">
|
||
<div class="modal-dialog modal-dialog-centered">
|
||
<div class="modal-content">
|
||
<div class="modal-header border-0 pb-0">
|
||
<h5 class="modal-title" id="approveQuoteModalLabel">
|
||
<i class="bi bi-check-circle text-success me-2"></i>Approve Quote
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<p>Are you sure you want to approve <strong>@Model.QuoteNumber</strong>?</p>
|
||
<p class="text-muted small mb-0">The quote status will be updated to Approved@(ViewBag.EmailDefaultOnApprove == true ? " and the customer will be notified via email" : "").</p>
|
||
</div>
|
||
<div class="modal-footer border-0 pt-0">
|
||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||
<button type="button" class="btn btn-success" onclick="document.getElementById('approveQuoteForm').submit()">
|
||
<i class="bi bi-check-circle me-1"></i>Yes, Approve Quote
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Create Job Confirmation Modal -->
|
||
<div class="modal fade" id="createJobModal" tabindex="-1" aria-labelledby="createJobModalLabel" aria-hidden="true">
|
||
<div class="modal-dialog modal-dialog-centered">
|
||
<div class="modal-content">
|
||
<div class="modal-header border-0 pb-0">
|
||
<h5 class="modal-title" id="createJobModalLabel">
|
||
<i class="bi bi-clipboard-check text-success me-2"></i>Create Job from Quote
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
@if (Model.IsProspect)
|
||
{
|
||
<p>This will automatically <strong>convert the prospect to a customer</strong> and create a new job from this quote.</p>
|
||
<p class="text-muted small mb-0">A customer record will be created using the prospect's contact information.</p>
|
||
}
|
||
else
|
||
{
|
||
<p>This will create a new job linked to <strong>@(Model.CustomerCompanyName ?? Model.CustomerName ?? "this customer")</strong> based on the items and details in this quote.</p>
|
||
<p class="text-muted small mb-0">The quote status will be updated and the job will be ready for scheduling.</p>
|
||
}
|
||
</div>
|
||
<div class="modal-footer border-0 pt-0">
|
||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||
<button type="button" class="btn btn-success" onclick="document.getElementById('createJobForm').submit()">
|
||
<i class="bi bi-clipboard-check me-1"></i>Yes, Create Job
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
@section Styles {
|
||
<style>
|
||
/* Hide print-only content on screen */
|
||
.print-only-items {
|
||
display: none;
|
||
}
|
||
|
||
/* Hide mobile card view on desktop screens */
|
||
@@media (min-width: 768px) {
|
||
.mobile-card-list {
|
||
display: none !important;
|
||
}
|
||
}
|
||
|
||
/* Print styles */
|
||
@@media print {
|
||
/* Show print-only items table */
|
||
.print-only-items {
|
||
display: block !important;
|
||
}
|
||
|
||
/* Hide the separate catalog/calculated items sections */
|
||
.screen-only-items {
|
||
display: none !important;
|
||
}
|
||
|
||
/* Hide mobile card view */
|
||
.mobile-card-list {
|
||
display: none !important;
|
||
}
|
||
|
||
/* Hide action buttons and debug sections */
|
||
.btn, button,
|
||
#pricingBreakdown,
|
||
#pricingDebug,
|
||
[data-bs-toggle="collapse"] {
|
||
display: none !important;
|
||
}
|
||
|
||
/* Hide the actions card entirely */
|
||
.card:has(.bi-gear) {
|
||
display: none !important;
|
||
}
|
||
|
||
/* Simplify pricing summary for print */
|
||
.card-header {
|
||
background-color: #f8f9fa !important;
|
||
color: #000 !important;
|
||
-webkit-print-color-adjust: exact;
|
||
print-color-adjust: exact;
|
||
}
|
||
|
||
/* Ensure tables print with borders */
|
||
.table-bordered {
|
||
border: 1px solid #dee2e6 !important;
|
||
}
|
||
|
||
.table-bordered th,
|
||
.table-bordered td {
|
||
border: 1px solid #dee2e6 !important;
|
||
}
|
||
|
||
/* Compact spacing for print */
|
||
.container-fluid {
|
||
padding: 0 !important;
|
||
}
|
||
|
||
.card {
|
||
page-break-inside: avoid;
|
||
margin-bottom: 1rem !important;
|
||
}
|
||
|
||
/* Ensure proper sizing */
|
||
body {
|
||
font-size: 10pt;
|
||
}
|
||
|
||
.table {
|
||
font-size: 9pt;
|
||
}
|
||
|
||
/* Keep table headers with content */
|
||
thead {
|
||
display: table-header-group;
|
||
}
|
||
|
||
tfoot {
|
||
display: table-footer-group;
|
||
}
|
||
|
||
/* Prevent page breaks in table rows */
|
||
tr {
|
||
page-break-inside: avoid;
|
||
}
|
||
|
||
/* Hide profile pictures and non-essential content */
|
||
img {
|
||
display: none !important;
|
||
}
|
||
|
||
/* Simplify layout to single column for print */
|
||
.row > .col-lg-8,
|
||
.row > .col-lg-4 {
|
||
width: 100% !important;
|
||
max-width: 100% !important;
|
||
}
|
||
}
|
||
</style>
|
||
}
|
||
|
||
<!-- Send Quote Modal -->
|
||
<div class="modal fade" id="sendQuoteModal" tabindex="-1" aria-labelledby="sendQuoteModalLabel" aria-hidden="true">
|
||
<div class="modal-dialog modal-sm">
|
||
<div class="modal-content">
|
||
<div class="modal-header" id="sendQuoteModalHeader">
|
||
<h5 class="modal-title" id="sendQuoteModalLabel">
|
||
<i class="bi bi-envelope-arrow-up me-2"></i>Send Quote to Customer
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||
</div>
|
||
<div class="modal-body text-center" id="sendQuoteBody">
|
||
<div id="sendQuoteSending">
|
||
<div class="spinner-border text-primary mb-3" role="status"></div>
|
||
<div class="text-muted">Sending quote…</div>
|
||
</div>
|
||
<div id="sendQuoteResult" class="d-none">
|
||
<i id="sendQuoteIcon" class="fs-1 d-block mb-3"></i>
|
||
<p id="sendQuoteMessage" class="mb-0"></p>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer d-none" id="sendQuoteFooter">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Notifications Sent Modal -->
|
||
<div class="modal fade" id="notificationsModal" tabindex="-1" aria-labelledby="notificationsModalLabel" aria-hidden="true">
|
||
<div class="modal-dialog modal-lg">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title" id="notificationsModalLabel">
|
||
<i class="bi bi-bell me-2"></i>Notifications Sent
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div id="notificationsLoading" class="text-center py-4">
|
||
<div class="spinner-border text-primary" role="status"></div>
|
||
<div class="mt-2 text-muted">Loading...</div>
|
||
</div>
|
||
<div id="notificationsEmpty" class="text-center py-4 text-muted d-none">
|
||
<i class="bi bi-bell-slash fs-2 d-block mb-2"></i>No notifications have been sent for this quote.
|
||
</div>
|
||
<div id="notificationsTable" class="d-none">
|
||
<table class="table table-sm table-hover mb-0">
|
||
<thead>
|
||
<tr>
|
||
<th>Sent At</th>
|
||
<th>Type</th>
|
||
<th>Channel</th>
|
||
<th>Recipient</th>
|
||
<th>Subject</th>
|
||
<th>Status</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="notificationsBody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
@section Scripts {
|
||
<script src="~/js/customer-change.js" asp-append-version="true"></script>
|
||
<script>
|
||
function resendQuote(quoteId) {
|
||
// Reset modal state
|
||
document.getElementById('sendQuoteSending').classList.remove('d-none');
|
||
document.getElementById('sendQuoteResult').classList.add('d-none');
|
||
document.getElementById('sendQuoteFooter').classList.add('d-none');
|
||
document.getElementById('sendQuoteModalHeader').className = 'modal-header';
|
||
|
||
const modal = new bootstrap.Modal(document.getElementById('sendQuoteModal'));
|
||
modal.show();
|
||
|
||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||
|
||
fetch('@Url.Action("ResendQuote", "Quotes")?id=' + quoteId, {
|
||
method: 'POST',
|
||
headers: { 'RequestVerificationToken': token, 'X-Requested-With': 'XMLHttpRequest' }
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
document.getElementById('sendQuoteSending').classList.add('d-none');
|
||
document.getElementById('sendQuoteResult').classList.remove('d-none');
|
||
document.getElementById('sendQuoteFooter').classList.remove('d-none');
|
||
|
||
const icon = document.getElementById('sendQuoteIcon');
|
||
const msg = document.getElementById('sendQuoteMessage');
|
||
const header = document.getElementById('sendQuoteModalHeader');
|
||
|
||
if (data.success) {
|
||
icon.className = 'bi bi-check-circle-fill text-success fs-1 d-block mb-3';
|
||
header.className = 'modal-header bg-success text-white';
|
||
showInfo(data.message, 'Email Sent');
|
||
} else {
|
||
icon.className = 'bi bi-x-circle-fill text-danger fs-1 d-block mb-3';
|
||
header.className = 'modal-header bg-danger text-white';
|
||
showWarning(data.message, 'Email Not Sent');
|
||
}
|
||
msg.textContent = data.message;
|
||
})
|
||
.catch(() => {
|
||
document.getElementById('sendQuoteSending').classList.add('d-none');
|
||
document.getElementById('sendQuoteResult').classList.remove('d-none');
|
||
document.getElementById('sendQuoteFooter').classList.remove('d-none');
|
||
document.getElementById('sendQuoteIcon').className = 'bi bi-x-circle-fill text-danger fs-1 d-block mb-3';
|
||
document.getElementById('sendQuoteModalHeader').className = 'modal-header bg-danger text-white';
|
||
document.getElementById('sendQuoteMessage').textContent = 'A network error occurred. Please try again.';
|
||
showWarning('A network error occurred. Please try again.', 'Email Not Sent');
|
||
});
|
||
}
|
||
|
||
function loadNotifications(quoteId) {
|
||
const modal = new bootstrap.Modal(document.getElementById('notificationsModal'));
|
||
document.getElementById('notificationsLoading').classList.remove('d-none');
|
||
document.getElementById('notificationsEmpty').classList.add('d-none');
|
||
document.getElementById('notificationsTable').classList.add('d-none');
|
||
modal.show();
|
||
|
||
fetch('@Url.Action("NotificationsSent", "Quotes")?id=' + quoteId)
|
||
.then(r => r.json())
|
||
.then(logs => {
|
||
document.getElementById('notificationsLoading').classList.add('d-none');
|
||
if (!logs.length) {
|
||
document.getElementById('notificationsEmpty').classList.remove('d-none');
|
||
return;
|
||
}
|
||
const tbody = document.getElementById('notificationsBody');
|
||
tbody.innerHTML = logs.map(n => {
|
||
const statusClass = n.status === 'Sent' ? 'success' : n.status === 'Failed' ? 'danger' : 'secondary';
|
||
const channelIcon = n.channel === 'Email' ? 'bi-envelope' : 'bi-phone';
|
||
const errorRow = n.errorMessage
|
||
? `<tr><td colspan="6" class="text-danger small ps-3"><i class="bi bi-exclamation-triangle me-1"></i>${escHtml(n.errorMessage)}</td></tr>`
|
||
: '';
|
||
return `<tr>
|
||
<td class="text-nowrap small">${escHtml(n.sentAt)}</td>
|
||
<td class="small">${escHtml(n.type.replace(/([A-Z])/g, ' $1').trim())}</td>
|
||
<td class="small"><i class="bi ${channelIcon} me-1"></i>${escHtml(n.channel)}</td>
|
||
<td class="small">${escHtml(n.recipientName)}<br><span class="text-muted">${escHtml(n.recipient)}</span></td>
|
||
<td class="small">${n.subject ? escHtml(n.subject) : '<span class="text-muted">—</span>'}</td>
|
||
<td><span class="badge bg-${statusClass}">${escHtml(n.status)}</span></td>
|
||
</tr>${errorRow}`;
|
||
}).join('');
|
||
document.getElementById('notificationsTable').classList.remove('d-none');
|
||
})
|
||
.catch(() => {
|
||
document.getElementById('notificationsLoading').classList.add('d-none');
|
||
document.getElementById('notificationsBody').innerHTML =
|
||
'<tr><td colspan="6" class="text-danger">Failed to load notifications.</td></tr>';
|
||
document.getElementById('notificationsTable').classList.remove('d-none');
|
||
});
|
||
}
|
||
|
||
function escHtml(str) {
|
||
if (!str) return '';
|
||
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
function printQuotePdf(quoteId) {
|
||
// Construct the URL for inline PDF viewing
|
||
const pdfUrl = '@Url.Action("DownloadPdf", "Quotes")?id=' + quoteId + '&inline=true';
|
||
|
||
// Open PDF in a new window
|
||
const printWindow = window.open(pdfUrl, '_blank');
|
||
|
||
// Wait for PDF to load, then trigger print
|
||
if (printWindow) {
|
||
printWindow.onload = function() {
|
||
// Small delay to ensure PDF is fully rendered
|
||
setTimeout(function() {
|
||
printWindow.print();
|
||
}, 500);
|
||
};
|
||
}
|
||
}
|
||
|
||
// ── Deposits ─────────────────────────────────────────────────────────
|
||
function getAntiForgeryToken() {
|
||
return document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||
}
|
||
|
||
document.getElementById('addDepositForm')?.addEventListener('submit', async function(e) {
|
||
e.preventDefault();
|
||
const form = e.currentTarget;
|
||
const btn = document.getElementById('saveDepositBtn');
|
||
const errEl = document.getElementById('depositFormError');
|
||
|
||
function showDepositError(msg) {
|
||
if (errEl) { errEl.textContent = msg; errEl.classList.remove('d-none'); }
|
||
else { alert(msg); }
|
||
}
|
||
|
||
if (errEl) errEl.classList.add('d-none');
|
||
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving…'; }
|
||
|
||
const params = new URLSearchParams(new FormData(form));
|
||
try {
|
||
const resp = await fetch('@Url.Action("Record", "Deposits")', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/x-www-form-urlencoded',
|
||
'RequestVerificationToken': getAntiForgeryToken()
|
||
},
|
||
body: params.toString()
|
||
});
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
window.open('@Url.Action("Receipt", "Deposits")/' + data.depositId, '_blank');
|
||
location.reload();
|
||
} else {
|
||
showDepositError(data.message || 'An error occurred.');
|
||
}
|
||
} catch {
|
||
showDepositError('A network error occurred.');
|
||
} finally {
|
||
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-check-circle me-1"></i>Save & Generate Receipt'; }
|
||
}
|
||
});
|
||
|
||
async function deleteDeposit(depositId, receiptNum) {
|
||
if (!confirm(`Delete deposit ${receiptNum}? This cannot be undone.`)) return;
|
||
try {
|
||
const resp = await fetch('@Url.Action("Delete", "Deposits")/' + depositId, {
|
||
method: 'POST',
|
||
headers: { 'RequestVerificationToken': getAntiForgeryToken() }
|
||
});
|
||
const data = await resp.json();
|
||
if (data.success) location.reload();
|
||
else alert(data.message || 'Could not delete the deposit.');
|
||
} catch {
|
||
alert('A network error occurred.');
|
||
}
|
||
}
|
||
</script>
|
||
}
|