Files
PowderCoatingLogix/src/PowderCoating.Web/Views/Quotes/Details.cshtml
T
2026-05-06 21:06:24 -04:00

2422 lines
151 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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="&lt;strong&gt;Draft&lt;/strong&gt; — saved but not yet sent. Edit freely.&lt;br&gt;&lt;strong&gt;Sent&lt;/strong&gt; — delivered to the customer, awaiting response.&lt;br&gt;&lt;strong&gt;Approved&lt;/strong&gt; — customer accepted. You can convert this to a Job.&lt;br&gt;&lt;strong&gt;Rejected&lt;/strong&gt; — customer declined.&lt;br&gt;&lt;strong&gt;Expired&lt;/strong&gt; — validity period has passed. Edit to extend it.&lt;br&gt;&lt;strong&gt;Converted&lt;/strong&gt; — a job has been created from this quote.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-statuses' target='_blank'&gt;Learn more →&lt;/a&gt;">
<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 class="mt-2 small" id="qpCaptionRow" style="display:none;">
<strong>Caption:</strong> <span id="qpCaption"></span>
</div>
<!-- Caption edit form (hidden by default) -->
<div id="qpEditPanel" class="d-none text-start mt-3">
<label class="form-label fw-semibold">Caption / Note</label>
<textarea class="form-control" id="qpEditCaption" rows="2" placeholder="Add a caption or note..."></textarea>
</div>
</div>
<div class="modal-footer">
<div id="qpViewButtons" class="d-flex gap-2 w-100 justify-content-end">
<button type="button" class="btn btn-outline-secondary" id="qpEditBtn" onclick="qpGallery.editPhoto()">
<i class="bi bi-pencil me-1"></i>Edit Caption
</button>
<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 id="qpEditButtons" class="d-none d-flex gap-2 w-100 justify-content-end">
<button type="button" class="btn btn-secondary" onclick="qpGallery.cancelEdit()">Cancel</button>
<button type="button" class="btn btn-primary" onclick="qpGallery.saveEdit()">
<i class="bi bi-check-lg me-1"></i>Save Caption
</button>
</div>
</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,
caption = p.Caption
})));
let currentIndex = 0;
const quoteId = @Model.Id;
const uploadUrl = '@Url.Action("UploadQuotePhoto", "Quotes")';
const deleteUrl = '@Url.Action("DeleteQuotePhoto", "Quotes")';
const updateUrl = '@Url.Action("UpdateQuotePhoto", "Quotes")';
const token = () => document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
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('qpEditBtn').style.display = p.isAi ? 'none' : '';
document.getElementById('qpPrev').disabled = photos.length <= 1;
document.getElementById('qpNext').disabled = photos.length <= 1;
const captionRow = document.getElementById('qpCaptionRow');
if (captionRow) {
document.getElementById('qpCaption').textContent = p.caption || '';
captionRow.style.display = p.caption ? '' : 'none';
}
}
function open(index) {
currentIndex = index;
render();
bootstrap.Modal.getOrCreateInstance(document.getElementById('qpModal')).show();
}
function navigate(dir) {
const editPanel = document.getElementById('qpEditPanel');
if (editPanel && !editPanel.classList.contains('d-none')) cancelEdit();
currentIndex = (currentIndex + dir + photos.length) % photos.length;
render();
}
function editPhoto() {
document.getElementById('qpEditCaption').value = photos[currentIndex].caption || '';
document.getElementById('qpCaptionRow').style.display = 'none';
document.getElementById('qpEditPanel').classList.remove('d-none');
document.getElementById('qpViewButtons').classList.add('d-none');
document.getElementById('qpEditButtons').classList.remove('d-none');
}
function cancelEdit() {
document.getElementById('qpEditPanel').classList.add('d-none');
document.getElementById('qpCaptionRow').style.display = photos[currentIndex].caption ? '' : 'none';
document.getElementById('qpEditButtons').classList.add('d-none');
document.getElementById('qpViewButtons').classList.remove('d-none');
}
async function saveEdit() {
const p = photos[currentIndex];
const caption = document.getElementById('qpEditCaption').value.trim();
const fd = new FormData();
fd.append('id', p.id);
fd.append('caption', caption);
fd.append('__RequestVerificationToken', token());
const resp = await fetch(updateUrl, { method: 'POST', body: fd });
const data = await resp.json();
if (!data.success) { alert(data.error || 'Update failed.'); return; }
p.caption = caption || null;
cancelEdit();
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, caption: null });
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;
}
// Reset to view mode whenever the modal is closed
document.getElementById('qpModal')?.addEventListener('hidden.bs.modal', () => {
const editPanel = document.getElementById('qpEditPanel');
if (editPanel && !editPanel.classList.contains('d-none')) cancelEdit();
});
return { open, navigate, deletePhoto, editPhoto, cancelEdit, saveEdit };
})();
</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 > 0 ? $" × {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 > 0 ? $", {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> &middot; </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">&times;@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>
@{
var detHasEmail = !string.IsNullOrWhiteSpace(Model.CustomerEmail);
var detHasMobile = !string.IsNullOrWhiteSpace(Model.CustomerMobilePhone);
var detHasSmsConsent = Model.CustomerNotifyBySms && detHasMobile;
}
@if (Model.StatusCode != "APPROVED" && Model.StatusCode != "CONVERTED")
{
<form asp-action="ApproveQuote" asp-route-id="@Model.Id" method="post" id="approveQuoteForm">
@Html.AntiForgeryToken()
@if (detHasEmail)
{
<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>
}
@if (detHasEmail)
{
<button type="button" class="btn btn-outline-primary" onclick="resendQuote(@Model.Id)">
<i class="bi bi-envelope-arrow-up me-1"></i>Send Quote via Email
</button>
}
@if (detHasMobile)
{
<button type="button" class="btn btn-outline-info" onclick="sendQuoteSms(@Model.Id)">
<i class="bi bi-chat-dots me-1"></i>Send Quote via SMS
</button>
}
@if (!detHasMobile && !detHasEmail)
{
<div class="alert alert-warning alert-permanent py-1 px-2 small">
<i class="bi bi-exclamation-triangle me-1"></i>No email or mobile number on file — update the customer record to send this quote electronically.
</div>
}
@if (detHasMobile && !detHasSmsConsent)
{
<div class="text-muted small"><i class="bi bi-phone-slash me-1"></i>SMS consent required to send via text.</div>
}
@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 @Model.ConvertedToJobNumber
</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 via SMS Modal -->
<div class="modal fade" id="sendQuoteSmsModal" tabindex="-1" aria-labelledby="sendQuoteSmsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header" id="sendQuoteSmsModalHeader">
<h5 class="modal-title" id="sendQuoteSmsModalLabel">
<i class="bi bi-chat-dots me-2"></i>Send Quote via SMS
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-center" id="sendQuoteSmsBody">
<div id="sendQuoteSmsSending">
<div class="spinner-border text-info mb-3" role="status"></div>
<div class="text-muted">Sending SMS&hellip;</div>
</div>
<div id="sendQuoteSmsResult" class="d-none">
<i id="sendQuoteSmsIcon" class="fs-1 d-block mb-3"></i>
<p id="sendQuoteSmsMessage" class="mb-0"></p>
</div>
</div>
<div class="modal-footer d-none" id="sendQuoteSmsFooter">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- 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&hellip;</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 sendQuoteSms(quoteId) {
document.getElementById('sendQuoteSmsSending').classList.remove('d-none');
document.getElementById('sendQuoteSmsResult').classList.add('d-none');
document.getElementById('sendQuoteSmsFooter').classList.add('d-none');
document.getElementById('sendQuoteSmsModalHeader').className = 'modal-header';
const modal = new bootstrap.Modal(document.getElementById('sendQuoteSmsModal'));
modal.show();
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
fetch('@Url.Action("SendQuoteApprovalSms", "Quotes")?id=' + quoteId, {
method: 'POST',
headers: { 'RequestVerificationToken': token, 'X-Requested-With': 'XMLHttpRequest' }
})
.then(r => r.json())
.then(data => {
document.getElementById('sendQuoteSmsSending').classList.add('d-none');
document.getElementById('sendQuoteSmsResult').classList.remove('d-none');
document.getElementById('sendQuoteSmsFooter').classList.remove('d-none');
const icon = document.getElementById('sendQuoteSmsIcon');
const msg = document.getElementById('sendQuoteSmsMessage');
const header = document.getElementById('sendQuoteSmsModalHeader');
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, 'SMS 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, 'SMS Not Sent');
}
msg.textContent = data.message;
})
.catch(() => {
document.getElementById('sendQuoteSmsSending').classList.add('d-none');
document.getElementById('sendQuoteSmsResult').classList.remove('d-none');
document.getElementById('sendQuoteSmsFooter').classList.remove('d-none');
document.getElementById('sendQuoteSmsIcon').className = 'bi bi-x-circle-fill text-danger fs-1 d-block mb-3';
document.getElementById('sendQuoteSmsModalHeader').className = 'modal-header bg-danger text-white';
document.getElementById('sendQuoteSmsMessage').textContent = 'A network error occurred. Please try again.';
showWarning('A network error occurred. Please try again.', 'SMS 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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>
}