Add pricing breakdown and powder pre-fill to Job Details; surface voided invoice history
- Job Details: collapsible internal pricing breakdown card mirrors quote details breakdown (items subtotal, shop supplies, discount, rush fee, tax, total) - Job Details: voided invoice history section shows previous invoices instead of hiding them - Complete Job modal: pre-fills powder usage from QR-scanned / manually logged entries so staff don't double-log; consumes pre-logged credit per InventoryItemId before deducting net delta - JobProfile: map ShopSuppliesAmount, ShopSuppliesPercent, IsRushJob, DiscountType/Value/Reason so the pricing breakdown has the data it needs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1315,6 +1315,7 @@
|
||||
}
|
||||
@{
|
||||
var panelInvoiceId = ViewBag.JobInvoiceId as int?;
|
||||
var voidedInvoices = ViewBag.JobVoidedInvoices as IEnumerable<dynamic> ?? [];
|
||||
}
|
||||
@if (panelInvoiceId.HasValue)
|
||||
{
|
||||
@@ -1330,6 +1331,13 @@
|
||||
<i class="bi bi-receipt me-2"></i>Create Invoice
|
||||
</a>
|
||||
}
|
||||
@foreach (var vi in voidedInvoices)
|
||||
{
|
||||
<a asp-controller="Invoices" asp-action="Details" asp-route-id="@vi.Id"
|
||||
class="btn btn-outline-secondary btn-sm" title="Voided invoice">
|
||||
<i class="bi bi-x-circle me-1 text-danger"></i>@vi.InvoiceNumber <span class="text-muted">(Voided)</span>
|
||||
</a>
|
||||
}
|
||||
<a asp-action="WorkOrder" asp-route-id="@Model.Id" class="btn btn-outline-secondary" target="_blank">
|
||||
<i class="bi bi-printer me-2"></i>Print Work Order
|
||||
</a>
|
||||
@@ -1360,17 +1368,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pricing Summary -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<!-- Pricing Summary (internal — d-print-none) -->
|
||||
@{
|
||||
var jobPb = ViewBag.JobPricingBreakdown as PowderCoating.Application.DTOs.Quote.QuotePricingBreakdownDto;
|
||||
}
|
||||
<div class="card border-0 shadow-sm mb-4 d-print-none">
|
||||
<div class="card-header bg-white border-0 py-3">
|
||||
<h5 class="mb-0 fw-semibold">
|
||||
<i class="bi bi-currency-dollar me-2 text-primary"></i>Pricing
|
||||
<i class="bi bi-cash-stack me-2 text-primary"></i>Pricing Summary
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (!string.IsNullOrWhiteSpace(Model.OvenLabel))
|
||||
{
|
||||
<div class="mb-3 p-2 bg-body-secondary rounded d-flex align-items-center">
|
||||
<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>
|
||||
@@ -1378,14 +1389,253 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="mb-3">
|
||||
<label class="text-muted small mb-1">Quoted Price</label>
|
||||
<p class="mb-0 fw-semibold">@Model.QuotedPrice.ToString("C")</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-muted small mb-1">Final Price</label>
|
||||
<h3 class="mb-0 text-primary">@Model.FinalPrice.ToString("C")</h3>
|
||||
</div>
|
||||
|
||||
@if (jobPb != null)
|
||||
{
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span>Items Subtotal:</span>
|
||||
<strong>@jobPb.ItemsSubtotal.ToString("C")</strong>
|
||||
</div>
|
||||
|
||||
@if (jobPb.OvenBatchCost > 0)
|
||||
{
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span><i class="bi bi-fire me-1"></i>Oven (@jobPb.OvenBatches batch@(jobPb.OvenBatches != 1 ? "es" : "")@(jobPb.OvenCycleMinutes > 0 ? $" × {jobPb.OvenCycleMinutes} min" : "")):</span>
|
||||
<strong>@jobPb.OvenBatchCost.ToString("C")</strong>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (jobPb.FacilityOverheadCost > 0)
|
||||
{
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span><i class="bi bi-building me-1"></i>Facility Overhead (@jobPb.FacilityOverheadRatePerHour.ToString("C2")/hr):</span>
|
||||
<strong>@jobPb.FacilityOverheadCost.ToString("C")</strong>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (jobPb.ShopSuppliesAmount > 0)
|
||||
{
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span>Shop Supplies (@jobPb.ShopSuppliesPercent%):</span>
|
||||
<strong>@jobPb.ShopSuppliesAmount.ToString("C")</strong>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span>Subtotal:</span>
|
||||
<strong>@jobPb.SubtotalBeforeDiscount.ToString("C")</strong>
|
||||
</div>
|
||||
|
||||
@if (jobPb.DiscountAmount > 0)
|
||||
{
|
||||
<div class="d-flex justify-content-between mb-2 text-success">
|
||||
<span>
|
||||
@if (Model.DiscountType == "Percentage")
|
||||
{
|
||||
<text>Discount (@Model.DiscountValue% Off):</text>
|
||||
}
|
||||
else if (Model.DiscountType == "FixedAmount")
|
||||
{
|
||||
<text>Discount (@Model.DiscountValue.ToString("C") Off):</text>
|
||||
}
|
||||
else
|
||||
{
|
||||
<text>Discount (@jobPb.DiscountPercent.ToString("F1")%):</text>
|
||||
}
|
||||
</span>
|
||||
<strong>-@jobPb.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.IsRushJob && jobPb.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>@jobPb.RushFee.ToString("C")</strong>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (jobPb.TaxAmount > 0)
|
||||
{
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span>Tax (@jobPb.TaxPercent.ToString("G29")%):</span>
|
||||
<strong>@jobPb.TaxAmount.ToString("C")</strong>
|
||||
</div>
|
||||
}
|
||||
|
||||
<hr />
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h5>Total:</h5>
|
||||
<h5 class="text-primary"><strong>@jobPb.Total.ToString("C")</strong></h5>
|
||||
</div>
|
||||
|
||||
@* Collapsible detail breakdown *@
|
||||
<button class="btn btn-sm btn-outline-secondary w-100" type="button" data-bs-toggle="collapse" data-bs-target="#jobPricingBreakdown">
|
||||
<i class="bi bi-calculator me-1"></i>Cost Breakdown
|
||||
</button>
|
||||
<div class="collapse mt-3" id="jobPricingBreakdown">
|
||||
@{
|
||||
var directCosts = jobPb.MaterialCosts + jobPb.LaborCosts + jobPb.EquipmentCosts;
|
||||
var hasCostBreakdown = directCosts > 0;
|
||||
var allCatalog = Model.Items != null && Model.Items.All(i => i.CatalogItemId.HasValue);
|
||||
}
|
||||
|
||||
@* Section 1: Item Costs *@
|
||||
<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>@jobPb.MaterialCosts.ToString("C")</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between small mb-1">
|
||||
<span class="text-muted">Labor</span>
|
||||
<span>@jobPb.LaborCosts.ToString("C")</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between small mb-1">
|
||||
<span class="text-muted">Equipment (oven + booth)</span>
|
||||
<span>@jobPb.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 (@jobPb.ProfitPercent.ToString("F0")% baked into item prices)</span>
|
||||
<span>@((jobPb.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.</div>
|
||||
}
|
||||
<div class="d-flex justify-content-between small fw-semibold border-top pt-1 mt-1">
|
||||
<span>Items subtotal</span>
|
||||
<span>@jobPb.ItemsSubtotal.ToString("C")</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Section 2: Job-Level Additions *@
|
||||
@if (jobPb.OvenBatchCost > 0 || jobPb.FacilityOverheadCost > 0 || jobPb.ShopSuppliesAmount > 0 || jobPb.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>Job-Level Additions
|
||||
</div>
|
||||
@if (jobPb.OvenBatchCost > 0)
|
||||
{
|
||||
<div class="d-flex justify-content-between small mb-1">
|
||||
<span class="text-muted">Oven batch (@jobPb.OvenBatches batch@(jobPb.OvenBatches != 1 ? "es" : "")@(jobPb.OvenCycleMinutes > 0 ? $", {jobPb.OvenCycleMinutes} min/cycle" : ""))</span>
|
||||
<span>@jobPb.OvenBatchCost.ToString("C")</span>
|
||||
</div>
|
||||
}
|
||||
@if (jobPb.FacilityOverheadCost > 0)
|
||||
{
|
||||
<div class="d-flex justify-content-between small mb-1">
|
||||
<span class="text-muted">Facility overhead (@jobPb.FacilityOverheadRatePerHour.ToString("C2")/hr × estimated hours)</span>
|
||||
<span>@jobPb.FacilityOverheadCost.ToString("C")</span>
|
||||
</div>
|
||||
}
|
||||
@if (jobPb.ShopSuppliesAmount > 0)
|
||||
{
|
||||
<div class="d-flex justify-content-between small mb-1">
|
||||
<span class="text-muted">Shop supplies (@jobPb.ShopSuppliesPercent.ToString("F1")%)</span>
|
||||
<span>@jobPb.ShopSuppliesAmount.ToString("C")</span>
|
||||
</div>
|
||||
}
|
||||
@if (jobPb.OverheadCosts > 0)
|
||||
{
|
||||
<div class="d-flex justify-content-between small mb-1">
|
||||
<span class="text-muted">Overhead (@jobPb.OverheadPercent.ToString("F1")%)</span>
|
||||
<span>@jobPb.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>@jobPb.SubtotalBeforeDiscount.ToString("C")</span>
|
||||
</div>
|
||||
@if (jobPb.DiscountAmount > 0)
|
||||
{
|
||||
<div class="d-flex justify-content-between small mb-1 text-success">
|
||||
<span>Discount (@jobPb.DiscountPercent.ToString("F1")%)</span>
|
||||
<span>-@jobPb.DiscountAmount.ToString("C")</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between small mb-1">
|
||||
<span class="text-muted">After discount</span>
|
||||
<span>@jobPb.SubtotalAfterDiscount.ToString("C")</span>
|
||||
</div>
|
||||
}
|
||||
@if (jobPb.RushFee > 0)
|
||||
{
|
||||
<div class="d-flex justify-content-between small mb-1">
|
||||
<span class="text-muted">Rush fee</span>
|
||||
<span>@jobPb.RushFee.ToString("C")</span>
|
||||
</div>
|
||||
}
|
||||
@if (jobPb.TaxAmount > 0)
|
||||
{
|
||||
<div class="d-flex justify-content-between small mb-1">
|
||||
<span class="text-muted">Tax (@jobPb.TaxPercent.ToString("G29")%)</span>
|
||||
<span>@jobPb.TaxAmount.ToString("C")</span>
|
||||
</div>
|
||||
}
|
||||
<div class="d-flex justify-content-between fw-bold border-top pt-2 mt-1">
|
||||
<span>Total</span>
|
||||
<span>@jobPb.Total.ToString("C")</span>
|
||||
</div>
|
||||
@{
|
||||
var jobTotalDirectCost = jobPb.MaterialCosts + jobPb.LaborCosts + jobPb.EquipmentCosts + jobPb.OvenBatchCost + jobPb.FacilityOverheadCost + jobPb.ShopSuppliesAmount;
|
||||
var jobGrossProfit = jobPb.Total - jobTotalDirectCost;
|
||||
var jobEffectiveMargin = jobPb.Total > 0 ? (jobGrossProfit / jobPb.Total * 100m) : 0m;
|
||||
}
|
||||
@if (jobTotalDirectCost > 0)
|
||||
{
|
||||
<div class="d-flex justify-content-between small mt-2 pt-1 border-top @(jobEffectiveMargin < 10 ? "text-danger" : jobEffectiveMargin < 20 ? "text-warning" : "text-success")">
|
||||
<span>Effective gross margin</span>
|
||||
<span class="fw-semibold">@jobEffectiveMargin.ToString("F1")%</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Fallback: no items yet *@
|
||||
@if (Model.QuoteId.HasValue)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="text-muted small mb-1">Quoted Price</label>
|
||||
<p class="mb-0 fw-semibold">@Model.QuotedPrice.ToString("C")</p>
|
||||
</div>
|
||||
}
|
||||
<div>
|
||||
<label class="text-muted small mb-1">Final Price</label>
|
||||
<h3 class="mb-0 text-primary">@Model.FinalPrice.ToString("C")</h3>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2584,7 +2834,8 @@
|
||||
|
||||
// Notes
|
||||
const notes = [];
|
||||
if (!d.hasPowderData) notes.push('⚠ Add powder cost per lb on coat records to include material cost.');
|
||||
if (!d.hasPowderData && d.hasPowderRateButNoQty) notes.push('⚠ Surface area not set on one or more items — edit the item and enter a surface area to calculate powder cost.');
|
||||
else if (!d.hasPowderData) notes.push('⚠ Add powder cost per lb on coat records to include material cost.');
|
||||
if (!d.hasLaborData) notes.push('⚠ Log time entries to include labor cost.');
|
||||
if (d.laborLines?.some(l => l.usingFallback)) notes.push('* One or more workers using standard labor rate fallback.');
|
||||
document.getElementById('costingNotes').innerHTML = notes.map(n => `<div class="text-muted">${n}</div>`).join('');
|
||||
@@ -2618,6 +2869,7 @@
|
||||
<script>
|
||||
const timeTracking = (() => {
|
||||
const jid = @Model.Id;
|
||||
const currentUserId = '@(ViewBag.CurrentUserId ?? "")';
|
||||
const modal = new bootstrap.Modal(document.getElementById('timeEntryModal'));
|
||||
let entries = [];
|
||||
|
||||
@@ -2673,7 +2925,7 @@
|
||||
function openAdd() {
|
||||
document.getElementById('timeEntryModalTitle').textContent = 'Log Time';
|
||||
document.getElementById('teEntryId').value = '0';
|
||||
document.getElementById('teWorkerId').value = '';
|
||||
document.getElementById('teWorkerId').value = currentUserId;
|
||||
document.getElementById('teWorkDate').value = new Date().toISOString().slice(0, 10);
|
||||
document.getElementById('teHoursWorked').value = '';
|
||||
document.getElementById('teStage').value = '';
|
||||
|
||||
Reference in New Issue
Block a user