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:
@@ -52,6 +52,7 @@ public class JobProfile : Profile
|
||||
.ForMember(dest => dest.PrepServiceIds, opt => opt.MapFrom(src =>
|
||||
src.JobPrepServices.Select(jps => jps.PrepServiceId).ToList()))
|
||||
.ForMember(dest => dest.TimeEntries, opt => opt.MapFrom(src => src.TimeEntries))
|
||||
.ForMember(dest => dest.DiscountType, opt => opt.MapFrom(src => src.DiscountType.ToString()))
|
||||
.ForMember(dest => dest.IsReworkJob, opt => opt.MapFrom(src => src.IsReworkJob))
|
||||
.ForMember(dest => dest.OriginalJobId, opt => opt.MapFrom(src => src.OriginalJobId))
|
||||
.ForMember(dest => dest.OriginalJobNumber,
|
||||
|
||||
@@ -395,11 +395,17 @@ public class JobsController : Controller
|
||||
ViewBag.UseMetric = useMetric;
|
||||
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
|
||||
|
||||
// Check if an invoice exists for this job
|
||||
var jobInvoice = await _unitOfWork.Invoices.GetForJobAsync(id.Value);
|
||||
// Separate active invoice from voided history for this job
|
||||
var allJobInvoices = await _unitOfWork.Invoices.FindAsync(i => i.JobId == id.Value);
|
||||
var jobInvoice = allJobInvoices.FirstOrDefault(i => i.Status != Core.Enums.InvoiceStatus.Voided);
|
||||
var voidedInvoices = allJobInvoices
|
||||
.Where(i => i.Status == Core.Enums.InvoiceStatus.Voided)
|
||||
.Select(i => new { i.Id, i.InvoiceNumber })
|
||||
.ToList<dynamic>();
|
||||
ViewBag.JobInvoiceId = jobInvoice?.Id;
|
||||
ViewBag.JobInvoiceNumber = jobInvoice?.InvoiceNumber;
|
||||
ViewBag.JobInvoiceStatus = jobInvoice?.Status;
|
||||
ViewBag.JobVoidedInvoices = voidedInvoices;
|
||||
|
||||
// Workers dropdown for inline assignment
|
||||
await PopulateWorkersDropdown();
|
||||
@@ -410,11 +416,79 @@ public class JobsController : Controller
|
||||
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
|
||||
.ToListAsync();
|
||||
ViewBag.ShopWorkers = companyUsers.Select(u => new { Id = u.Id, Name = u.FullName }).ToList();
|
||||
ViewBag.CurrentUserId = _userManager.GetUserId(User);
|
||||
|
||||
// Populate Edit Items wizard data (inline modal on Details page)
|
||||
var wizardCosts = await _pricingService.GetOperatingCostsAsync(job.CompanyId);
|
||||
await PopulateJobItemDropDownsAsync(job.CompanyId, wizardCosts?.OvenOperatingCostPerHour ?? 45m);
|
||||
ViewBag.WizardTaxPercent = wizardCosts?.TaxPercent ?? 0m;
|
||||
|
||||
// Internal pricing breakdown (not printed — mirrors quote details breakdown)
|
||||
var breakdownItems = job.JobItems
|
||||
.Where(ji => !ji.IsDeleted)
|
||||
.Select(ji => new CreateQuoteItemDto
|
||||
{
|
||||
Description = ji.Description,
|
||||
Quantity = ji.Quantity,
|
||||
SurfaceAreaSqFt = ji.SurfaceAreaSqFt,
|
||||
EstimatedMinutes = ji.EstimatedMinutes,
|
||||
CatalogItemId = ji.CatalogItemId,
|
||||
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && !ji.Coats.Any() && !ji.IsSalesItem),
|
||||
IsLaborItem = ji.IsLaborItem,
|
||||
IsSalesItem = ji.IsSalesItem,
|
||||
ManualUnitPrice = ji.ManualUnitPrice ?? ((ji.IsGenericItem || ji.IsSalesItem) ? ji.UnitPrice : (decimal?)null),
|
||||
PowderCostOverride = ji.PowderCostOverride,
|
||||
IncludePrepCost = ji.IncludePrepCost,
|
||||
Complexity = ji.Complexity,
|
||||
Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto
|
||||
{
|
||||
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb,
|
||||
PowderToOrder = c.PowderToOrder
|
||||
}).ToList(),
|
||||
PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto
|
||||
{
|
||||
PrepServiceId = ps.PrepServiceId,
|
||||
EstimatedMinutes = ps.EstimatedMinutes
|
||||
}).ToList()
|
||||
}).ToList();
|
||||
|
||||
if (breakdownItems.Any())
|
||||
{
|
||||
var pr = await _pricingService.CalculateQuoteTotalsAsync(
|
||||
breakdownItems, job.CompanyId, job.CustomerId,
|
||||
wizardCosts?.TaxPercent ?? 0m,
|
||||
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
|
||||
job.OvenCostId, 1, null);
|
||||
|
||||
ViewBag.JobPricingBreakdown = new QuotePricingBreakdownDto
|
||||
{
|
||||
MaterialCosts = pr.MaterialCosts,
|
||||
LaborCosts = pr.LaborCosts,
|
||||
EquipmentCosts = pr.EquipmentCosts,
|
||||
ItemsSubtotal = pr.ItemsSubtotal,
|
||||
OvenBatchCost = pr.OvenBatchCost,
|
||||
OvenBatches = pr.OvenBatches,
|
||||
OvenCycleMinutes = pr.OvenCycleMinutes > 0 ? pr.OvenCycleMinutes : (wizardCosts?.DefaultOvenCycleMinutes ?? 0),
|
||||
FacilityOverheadCost = pr.FacilityOverheadCost,
|
||||
FacilityOverheadRatePerHour = pr.FacilityOverheadRatePerHour,
|
||||
ShopSuppliesAmount = pr.ShopSuppliesAmount,
|
||||
ShopSuppliesPercent = pr.ShopSuppliesPercent,
|
||||
OverheadCosts = pr.OverheadCosts,
|
||||
OverheadPercent = pr.OverheadPercent,
|
||||
ProfitMargin = pr.ProfitMargin,
|
||||
ProfitPercent = pr.ProfitPercent,
|
||||
SubtotalBeforeDiscount = pr.SubtotalBeforeDiscount,
|
||||
DiscountAmount = pr.DiscountAmount,
|
||||
DiscountPercent = pr.DiscountPercent,
|
||||
SubtotalAfterDiscount = pr.SubtotalAfterDiscount,
|
||||
RushFee = pr.RushFee,
|
||||
TaxAmount = pr.TaxAmount,
|
||||
TaxPercent = pr.TaxPercent,
|
||||
Total = pr.Total
|
||||
};
|
||||
}
|
||||
ViewBag.ComplexitySimplePercent = wizardCosts?.ComplexitySimplePercent ?? 0m;
|
||||
ViewBag.ComplexityModeratePercent = wizardCosts?.ComplexityModeratePercent ?? 5m;
|
||||
ViewBag.ComplexityComplexPercent = wizardCosts?.ComplexityComplexPercent ?? 15m;
|
||||
@@ -465,9 +539,15 @@ public class JobsController : Controller
|
||||
ViewBag.Deposits = jobDeposits;
|
||||
|
||||
// Materials used on this job via QR scan or manual log
|
||||
ViewBag.MaterialsUsed = (await _unitOfWork.InventoryTransactions.FindAsync(
|
||||
var allJobTransactions = (await _unitOfWork.InventoryTransactions.FindAsync(
|
||||
t => t.JobId == id.Value, false, t => t.InventoryItem))
|
||||
.OrderByDescending(t => t.TransactionDate).ToList();
|
||||
ViewBag.MaterialsUsed = allJobTransactions;
|
||||
|
||||
// Pre-logged powder grouped by InventoryItemId (for Complete Job modal pre-fill)
|
||||
ViewBag.PreLoggedPowder = allJobTransactions
|
||||
.GroupBy(t => t.InventoryItemId)
|
||||
.ToDictionary(g => g.Key, g => Math.Abs(g.Sum(t => t.Quantity)));
|
||||
|
||||
// Job photo subscription limits — used to disable the upload button in the view
|
||||
var photoCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
@@ -1153,7 +1233,9 @@ public class JobsController : Controller
|
||||
createCosts?.TaxPercent ?? 0m,
|
||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, null, 1, null);
|
||||
|
||||
job.FinalPrice = totals.Total;
|
||||
job.FinalPrice = totals.Total;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
job.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.Jobs.UpdateAsync(job);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
@@ -1667,7 +1749,9 @@ public class JobsController : Controller
|
||||
dto.JobItems, companyId, dto.CustomerId,
|
||||
editCosts?.TaxPercent ?? 0m,
|
||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, null, 1, null);
|
||||
job.FinalPrice = totals.Total;
|
||||
job.FinalPrice = totals.Total;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
}
|
||||
|
||||
// Save change history records
|
||||
@@ -2702,6 +2786,15 @@ public class JobsController : Controller
|
||||
job.JobStatusId = completedStatus.Id;
|
||||
}
|
||||
|
||||
// Build a mutable credit map: lbs already deducted from inventory for this job
|
||||
// (via QR scan / LogUsage before completion). We consume this credit per InventoryItemId
|
||||
// so we only deduct the net delta and never double-subtract.
|
||||
var preLoggedTransactions = await _unitOfWork.InventoryTransactions.FindAsync(
|
||||
t => t.JobId == dto.JobId);
|
||||
var preLoggedCredit = preLoggedTransactions
|
||||
.GroupBy(t => t.InventoryItemId)
|
||||
.ToDictionary(g => g.Key, g => Math.Abs(g.Sum(t => t.Quantity)));
|
||||
|
||||
// Update actual powder usage for each coat
|
||||
foreach (var coatUsage in dto.CoatUsages)
|
||||
{
|
||||
@@ -2723,37 +2816,49 @@ public class JobsController : Controller
|
||||
coatUsage.ActualPowderUsedLbs.HasValue &&
|
||||
coatUsage.ActualPowderUsedLbs.Value > 0)
|
||||
{
|
||||
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(jobItemCoat.InventoryItemId.Value);
|
||||
if (inventoryItem != null)
|
||||
var invItemId = jobItemCoat.InventoryItemId.Value;
|
||||
var actualLbs = coatUsage.ActualPowderUsedLbs.Value;
|
||||
|
||||
// Apply available pre-logged credit so we don't double-deduct
|
||||
var credit = preLoggedCredit.GetValueOrDefault(invItemId, 0m);
|
||||
var deductNow = Math.Max(0m, actualLbs - credit);
|
||||
// Consume credit (other coats sharing the same powder get whatever remains)
|
||||
preLoggedCredit[invItemId] = Math.Max(0m, credit - actualLbs);
|
||||
|
||||
if (deductNow > 0)
|
||||
{
|
||||
// Create inventory transaction to track the usage
|
||||
var transaction = new InventoryTransaction
|
||||
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(invItemId);
|
||||
if (inventoryItem != null)
|
||||
{
|
||||
InventoryItemId = inventoryItem.Id,
|
||||
TransactionType = InventoryTransactionType.JobUsage,
|
||||
Quantity = -coatUsage.ActualPowderUsedLbs.Value, // Negative for deduction
|
||||
UnitCost = inventoryItem.UnitCost,
|
||||
TotalCost = inventoryItem.UnitCost * coatUsage.ActualPowderUsedLbs.Value,
|
||||
TransactionDate = DateTime.UtcNow,
|
||||
JobId = job.Id,
|
||||
Reference = job.JobNumber,
|
||||
Notes = $"Powder used for Job {job.JobNumber} - {jobItemCoat.CoatName} ({jobItemCoat.ColorName ?? "N/A"}) by {currentUser!.FirstName} {currentUser.LastName}",
|
||||
BalanceAfter = inventoryItem.QuantityOnHand - coatUsage.ActualPowderUsedLbs.Value,
|
||||
CompanyId = job.CompanyId
|
||||
};
|
||||
var transaction = new InventoryTransaction
|
||||
{
|
||||
InventoryItemId = inventoryItem.Id,
|
||||
TransactionType = InventoryTransactionType.JobUsage,
|
||||
Quantity = -deductNow,
|
||||
UnitCost = inventoryItem.UnitCost,
|
||||
TotalCost = inventoryItem.UnitCost * deductNow,
|
||||
TransactionDate = DateTime.UtcNow,
|
||||
JobId = job.Id,
|
||||
Reference = job.JobNumber,
|
||||
Notes = $"Powder used for Job {job.JobNumber} - {jobItemCoat.CoatName} ({jobItemCoat.ColorName ?? "N/A"}) by {currentUser!.FirstName} {currentUser.LastName}",
|
||||
BalanceAfter = inventoryItem.QuantityOnHand - deductNow,
|
||||
CompanyId = job.CompanyId
|
||||
};
|
||||
|
||||
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
|
||||
|
||||
// Update inventory item quantity
|
||||
inventoryItem.QuantityOnHand -= coatUsage.ActualPowderUsedLbs.Value;
|
||||
await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem);
|
||||
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
|
||||
inventoryItem.QuantityOnHand -= deductNow;
|
||||
await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Deducted {Lbs} lbs (net of pre-logged) of {Item} from inventory for Job {JobNumber}. New quantity: {NewQty}",
|
||||
deductNow, inventoryItem.Name, job.JobNumber, inventoryItem.QuantityOnHand);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Deducted {Lbs} lbs of {Item} from inventory for Job {JobNumber}. New quantity: {NewQty}",
|
||||
coatUsage.ActualPowderUsedLbs.Value,
|
||||
inventoryItem.Name,
|
||||
job.JobNumber,
|
||||
inventoryItem.QuantityOnHand);
|
||||
"Skipped inventory deduction for JobItemCoat {CoatId} — {Lbs} lbs already pre-logged for inventory item {InvItemId}",
|
||||
coatUsage.JobItemCoatId, actualLbs, invItemId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3113,7 +3218,9 @@ public class JobsController : Controller
|
||||
model.JobItems, currentUser.CompanyId, job.CustomerId,
|
||||
model.TaxPercent, "None", 0, false, null, 1, null);
|
||||
|
||||
job.FinalPrice = totals.Total;
|
||||
job.FinalPrice = totals.Total;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
job.UpdatedAt = DateTime.UtcNow;
|
||||
job.UpdatedBy = currentUser.UserName;
|
||||
await _unitOfWork.Jobs.UpdateAsync(job);
|
||||
@@ -3184,11 +3291,15 @@ public class JobsController : Controller
|
||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||
remainingDtos, currentUser.CompanyId, job.CustomerId,
|
||||
costs?.TaxPercent ?? 0m, "None", 0, false, null, 1, null);
|
||||
job.FinalPrice = totals.Total;
|
||||
job.FinalPrice = totals.Total;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
}
|
||||
else
|
||||
{
|
||||
job.FinalPrice = 0;
|
||||
job.FinalPrice = 0;
|
||||
job.ShopSuppliesAmount = 0;
|
||||
job.ShopSuppliesPercent = 0;
|
||||
}
|
||||
|
||||
job.UpdatedAt = DateTime.UtcNow;
|
||||
@@ -3211,18 +3322,21 @@ public class JobsController : Controller
|
||||
var inventory = await _unitOfWork.InventoryItems.GetAllAsync(false, i => i.InventoryCategory);
|
||||
ViewBag.InventoryCoatings = inventory
|
||||
.Where(i => i.IsActive && i.InventoryCategory?.IsActive == true && i.InventoryCategory.IsCoating)
|
||||
.OrderBy(i => i.InventoryCategory!.DisplayOrder).ThenBy(i => i.ColorName ?? i.Name)
|
||||
.OrderBy(i => i.IsIncoming ? 1 : 0).ThenBy(i => i.InventoryCategory!.DisplayOrder).ThenBy(i => i.ColorName ?? i.Name)
|
||||
.Select(i => new
|
||||
{
|
||||
value = i.Id.ToString(),
|
||||
text = $"{i.InventoryCategory!.DisplayName} - {i.Manufacturer ?? "Generic"} - {i.ColorName ?? i.Name} - {i.ColorCode ?? "N/A"} ({i.UnitCost:C4}/unit)",
|
||||
text = i.IsIncoming
|
||||
? $"[INCOMING] {i.InventoryCategory!.DisplayName} - {i.Manufacturer ?? "Generic"} - {i.ColorName ?? i.Name} - {i.ColorCode ?? "N/A"} ({i.UnitCost:C4}/unit)"
|
||||
: $"{i.InventoryCategory!.DisplayName} - {i.Manufacturer ?? "Generic"} - {i.ColorName ?? i.Name} - {i.ColorCode ?? "N/A"} ({i.UnitCost:C4}/unit)",
|
||||
coverage = i.CoverageSqFtPerLb ?? 30m,
|
||||
efficiency = i.TransferEfficiency ?? 65m,
|
||||
unitOfMeasure = i.UnitOfMeasure ?? "lbs",
|
||||
categoryName = i.InventoryCategory.DisplayName,
|
||||
categoryName = i.InventoryCategory!.DisplayName,
|
||||
costPerLb = i.UnitCost,
|
||||
colorName = i.ColorName ?? i.Name,
|
||||
colorCode = i.ColorCode ?? ""
|
||||
colorCode = i.ColorCode ?? "",
|
||||
isIncoming = i.IsIncoming
|
||||
}).ToList();
|
||||
|
||||
var vendors = await _unitOfWork.Vendors.GetAllAsync(false);
|
||||
@@ -3896,9 +4010,11 @@ public class JobsController : Controller
|
||||
}
|
||||
|
||||
// Update pricing from quote and advance the snapshot so banner clears
|
||||
job.QuotedPrice = quote.Total;
|
||||
job.FinalPrice = quote.Total;
|
||||
job.QuoteSnapshotUpdatedAt = quote.UpdatedAt ?? quote.CreatedAt;
|
||||
job.QuotedPrice = quote.Total;
|
||||
job.FinalPrice = quote.Total;
|
||||
job.ShopSuppliesAmount = quote.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = quote.ShopSuppliesPercent;
|
||||
job.QuoteSnapshotUpdatedAt = quote.UpdatedAt ?? quote.CreatedAt;
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
});
|
||||
@@ -3933,7 +4049,8 @@ public class JobsController : Controller
|
||||
// Operating costs for fallback labor rate and oven rate
|
||||
var opCosts = (await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == companyId)).FirstOrDefault();
|
||||
var fallbackLaborRate = opCosts?.StandardLaborRate ?? 0m;
|
||||
var defaultOvenCycleHours = (opCosts?.DefaultOvenCycleMinutes ?? 45) / 60.0m;
|
||||
var effectiveOvenMinutes = (opCosts?.DefaultOvenCycleMinutes > 0 ? (int?)opCosts!.DefaultOvenCycleMinutes : null) ?? 45;
|
||||
var defaultOvenCycleHours = effectiveOvenMinutes / 60.0m;
|
||||
|
||||
// Role cost rates map: role → hourly rate
|
||||
var roleCosts = await _unitOfWork.ShopWorkerRoleCosts.FindAsync(r => r.CompanyId == companyId);
|
||||
@@ -3942,6 +4059,7 @@ public class JobsController : Controller
|
||||
// 1. Powder / Material cost
|
||||
decimal powderCost = 0m;
|
||||
var powderLines = new List<object>();
|
||||
bool hasCoatsWithRateButNoQty = false;
|
||||
foreach (var item in job.JobItems)
|
||||
{
|
||||
foreach (var coat in item.Coats)
|
||||
@@ -3960,6 +4078,11 @@ public class JobsController : Controller
|
||||
isActual = coat.ActualPowderUsedLbs.HasValue
|
||||
});
|
||||
}
|
||||
else if (costPerLb > 0 && lbs == 0)
|
||||
{
|
||||
// Coat has a price/lb but no quantity — surface area missing on the item
|
||||
hasCoatsWithRateButNoQty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4040,7 +4163,7 @@ public class JobsController : Controller
|
||||
laborCost = Math.Round(laborCost, 2),
|
||||
ovenCost = Math.Round(ovenCost, 2),
|
||||
ovenLabel,
|
||||
ovenCycleMinutes = opCosts?.DefaultOvenCycleMinutes ?? 45,
|
||||
ovenCycleMinutes = effectiveOvenMinutes,
|
||||
reworkCostTotal = Math.Round(reworkCostTotal, 2),
|
||||
reworkBilledToCustomer = Math.Round(reworkBilledToCustomer, 2),
|
||||
netReworkCost = Math.Round(netReworkCost, 2),
|
||||
@@ -4055,6 +4178,7 @@ public class JobsController : Controller
|
||||
powderLines,
|
||||
laborLines,
|
||||
hasPowderData = powderLines.Count > 0,
|
||||
hasPowderRateButNoQty = hasCoatsWithRateButNoQty && powderLines.Count == 0,
|
||||
hasLaborData = laborLines.Count > 0
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -285,7 +285,7 @@
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
<div class="d-flex gap-2">
|
||||
<div class="d-flex gap-2 ms-auto">
|
||||
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-outline-secondary btn-lg">
|
||||
<i class="bi bi-x-circle me-1"></i>Cancel
|
||||
</a>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
@model PowderCoating.Application.DTOs.Job.JobDto
|
||||
@{
|
||||
var emailDefault = ViewBag.EmailDefaultOnComplete == true;
|
||||
var preLoggedPowder = ViewBag.PreLoggedPowder as Dictionary<int, decimal> ?? new Dictionary<int, decimal>();
|
||||
// Track remaining credit per InventoryItemId as we allocate it across coat rows
|
||||
var remainingCredit = preLoggedPowder.ToDictionary(kv => kv.Key, kv => kv.Value);
|
||||
}
|
||||
<div class="modal fade" id="completeJobModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
@@ -76,12 +79,27 @@
|
||||
<small class="text-muted">@((coat.PowderToOrder ?? 0).ToString("0.##"))</small>
|
||||
</td>
|
||||
<td>
|
||||
@{
|
||||
decimal preFilledLbs = 0m;
|
||||
if (coat.InventoryItemId.HasValue && remainingCredit.TryGetValue(coat.InventoryItemId.Value, out var availCredit) && availCredit > 0)
|
||||
{
|
||||
preFilledLbs = availCredit;
|
||||
remainingCredit[coat.InventoryItemId.Value] = 0m;
|
||||
}
|
||||
}
|
||||
<input type="hidden" name="CoatUsages[@coatIndex].JobItemCoatId" value="@coat.Id" />
|
||||
<input type="number"
|
||||
class="form-control form-control-sm"
|
||||
name="CoatUsages[@coatIndex].ActualPowderUsedLbs"
|
||||
step="0.01" min="0" placeholder="0.00"
|
||||
value="@(preFilledLbs > 0 ? preFilledLbs.ToString("0.##") : "")"
|
||||
style="max-width: 120px;">
|
||||
@if (preFilledLbs > 0)
|
||||
{
|
||||
<small class="text-success d-block mt-1">
|
||||
<i class="bi bi-check-circle me-1"></i>Already logged — inventory adjusted
|
||||
</small>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
coatIndex++;
|
||||
@@ -104,7 +122,7 @@
|
||||
</div>
|
||||
<div class="alert alert-info mb-0">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
<small>Enter the actual amount of powder used for each coat. Leave blank if not tracked.</small>
|
||||
<small>Pre-filled values were already logged via scan — inventory is already adjusted for those. You can edit the amount; only the difference will be applied to inventory.</small>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user