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:
2026-05-08 20:47:44 -04:00
parent 3278152d83
commit 0d980e651a
5 changed files with 454 additions and 59 deletions
+266 -14
View File
@@ -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 = '';