From 2e73cfab549eacf35ac7750d9004955f784259b1 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Wed, 6 May 2026 12:27:37 -0400 Subject: [PATCH] Miscellaneous UI and pricing updates from prior sessions - PricingCalculationService: powder coverage and specific gravity math fixes - Dashboard/Index: minor widget updates - Jobs/Details, Jobs/Intake: shop floor and intake view improvements - Quotes/Details: detail view updates - GiftCertificates/Details: detail view update - job-photos.js: photo gallery improvements Co-Authored-By: Claude Sonnet 4.6 --- .../Services/PricingCalculationService.cs | 33 ++++--- .../Views/Dashboard/Index.cshtml | 4 +- .../Views/GiftCertificates/Details.cshtml | 2 +- .../Views/Jobs/Details.cshtml | 54 +++++++++--- .../Views/Jobs/Intake.cshtml | 2 +- .../Views/Quotes/Details.cshtml | 85 ++++++++++++++++--- .../wwwroot/js/job-photos.js | 84 ++++++++++++++++++ 7 files changed, 226 insertions(+), 38 deletions(-) diff --git a/src/PowderCoating.Application/Services/PricingCalculationService.cs b/src/PowderCoating.Application/Services/PricingCalculationService.cs index 8c8e2a1..ff16d20 100644 --- a/src/PowderCoating.Application/Services/PricingCalculationService.cs +++ b/src/PowderCoating.Application/Services/PricingCalculationService.cs @@ -422,12 +422,14 @@ public class PricingCalculationService : IPricingCalculationService else { // Non-catalog: derive base from first coat's material + labor + equipment + markup + decimal coatLaborCost = 0m; // coat-only labor, used for coating booth (not prep/sandblast) if (item.Coats != null && item.Coats.Count > 0) { var firstCoatResult = await CalculateCoatPriceAsync( item.Coats[0], item.SurfaceAreaSqFt, item.Quantity, 0, item.EstimatedMinutes, companyId); totalMaterialCost = firstCoatResult.CoatMaterialCost; - totalLaborCost = firstCoatResult.CoatLaborCost; + coatLaborCost = firstCoatResult.CoatLaborCost; + totalLaborCost = coatLaborCost; } // Prep service labor (done once per item batch) @@ -443,9 +445,10 @@ public class PricingCalculationService : IPricingCalculationService // Consumables surcharge (5% of material) totalMaterialCost += totalMaterialCost * ConsumablesSurchargePercent; - // Equipment cost: coating booth only (oven cost moved to quote-level batch calculation) - var totalLaborHours = totalLaborCost / costs.StandardLaborRate; - totalEquipmentCost = totalLaborHours * costs.CoatingBoothCostPerHour; + // Equipment cost: coating booth only — use coat labor hours, not prep/sandblast hours + // (sandblasting happens in a blast cabinet, not the powder coating booth) + var coatLaborHours = costs.StandardLaborRate > 0 ? coatLaborCost / costs.StandardLaborRate : 0m; + totalEquipmentCost = coatLaborHours * costs.CoatingBoothCostPerHour; // Apply pricing mode: markup on material only, or target margin on total cost if (costs.PricingMode == PowderCoating.Core.Enums.PricingMode.MarginOnTotalCost) @@ -675,22 +678,24 @@ public class PricingCalculationService : IPricingCalculationService var effectiveBatches = Math.Max(1, ovenBatches); var fullOvenBatchCost = effectiveBatches * (effectiveCycleMinutes / 60m) * effectiveOvenRate; - // Scale oven cost by the fraction of total surface area coming from non-AI items. - // Use item count as a fallback when surface areas are all zero. - var totalSqFt = items.Sum(i => i.SurfaceAreaSqFt * i.Quantity); - var aiSqFt = items.Where(i => i.IsAiItem).Sum(i => i.SurfaceAreaSqFt * i.Quantity); - var nonAiSqFt = totalSqFt - aiSqFt; + // Only items with coating layers go in the oven — sandblast/prep-only items (zero coats) don't. + // Of those coating items, AI items already have oven cost baked into their AI price. + var coatingItems = items.Where(i => i.Coats != null && i.Coats.Any()).ToList(); + var nonAiCoatItems = coatingItems.Where(i => !i.IsAiItem).ToList(); decimal nonAiFraction; - if (totalSqFt > 0) + if (!coatingItems.Any()) { - nonAiFraction = nonAiSqFt / totalSqFt; + nonAiFraction = 0m; // No coated items — no oven charge } else { - var totalCount = items.Count; - var aiCount = items.Count(i => i.IsAiItem); - nonAiFraction = totalCount > 0 ? (decimal)(totalCount - aiCount) / totalCount : 1m; + var totalCoatSqFt = coatingItems.Sum(i => i.SurfaceAreaSqFt * i.Quantity); + var nonAiCoatSqFt = nonAiCoatItems.Sum(i => i.SurfaceAreaSqFt * i.Quantity); + if (totalCoatSqFt > 0) + nonAiFraction = nonAiCoatSqFt / totalCoatSqFt; + else + nonAiFraction = coatingItems.Count > 0 ? (decimal)nonAiCoatItems.Count / coatingItems.Count : 1m; } var ovenBatchCost = fullOvenBatchCost * nonAiFraction; diff --git a/src/PowderCoating.Web/Views/Dashboard/Index.cshtml b/src/PowderCoating.Web/Views/Dashboard/Index.cshtml index 46cdb0e..69dc8bd 100644 --- a/src/PowderCoating.Web/Views/Dashboard/Index.cshtml +++ b/src/PowderCoating.Web/Views/Dashboard/Index.cshtml @@ -479,7 +479,7 @@ Powder in Queue to be Ordered @Model.PowderOrdersNeededCount item@(Model.PowderOrdersNeededCount == 1 ? "" : "s") - Grouped by vendor · Mark lines as ordered to remove them + Grouped by vendor · Mark lines as ordered to remove them
@foreach (var vendorGroup in Model.PowderOrdersNeeded) @@ -574,7 +574,7 @@ Powder Ordered — Awaiting Receipt @Model.PowderOrdersPlacedCount item@(Model.PowderOrdersPlacedCount == 1 ? "" : "s") - Grouped by vendor · Enter lbs received to update inventory + Grouped by vendor · Enter lbs received to update inventory
@if (Model.PowderOrdersPlaced.Any()) diff --git a/src/PowderCoating.Web/Views/GiftCertificates/Details.cshtml b/src/PowderCoating.Web/Views/GiftCertificates/Details.cshtml index d4b1ff9..e6a42d5 100644 --- a/src/PowderCoating.Web/Views/GiftCertificates/Details.cshtml +++ b/src/PowderCoating.Web/Views/GiftCertificates/Details.cshtml @@ -38,7 +38,7 @@ } @if (Model.ExpiryDate.HasValue) { - · Expires @Model.ExpiryDate.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MMMM d, yyyy") + · Expires @Model.ExpiryDate.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MMMM d, yyyy") }
diff --git a/src/PowderCoating.Web/Views/Jobs/Details.cshtml b/src/PowderCoating.Web/Views/Jobs/Details.cshtml index 99f97e2..9729cde 100644 --- a/src/PowderCoating.Web/Views/Jobs/Details.cshtml +++ b/src/PowderCoating.Web/Views/Jobs/Details.cshtml @@ -366,7 +366,7 @@ } @if (!string.IsNullOrEmpty(coat.Finish)) { - · @coat.Finish + · @coat.Finish } @if (coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0) { @@ -493,7 +493,7 @@ } @if (!string.IsNullOrEmpty(coat.Finish)) { - · @coat.Finish + · @coat.Finish } @if (coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0) { @@ -1680,12 +1680,46 @@

Uploaded:

By:

+ +
+
+ + +
+
+ + +
+
+ + +
+
+
@@ -1747,7 +1781,7 @@ @item.Description @if (item.Quantity > 1) { - ×@item.Quantity + ×@item.Quantity } @@ -1940,7 +1974,7 @@
- Formula: L × W ÷ @(ViewBag.UseMetric == true ? "10,000" : "144") + Formula: L × W ÷ @(ViewBag.UseMetric == true ? "10,000" : "144") @@ -782,13 +801,15 @@ url = Url.Action("Photo", "Quotes", new { id = p.Id }), fileName = p.FileName, date = p.CreatedAt.ToString("MMM d, yyyy"), - isAi = p.IsAiAnalysisPhoto + 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() { @@ -799,8 +820,14 @@ 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) { @@ -810,10 +837,42 @@ } 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]; @@ -864,7 +923,7 @@ 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 }); + 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'); @@ -884,7 +943,13 @@ if (badge) badge.textContent = parseInt(badge.textContent || '0') + delta; } - return { open, navigate, deletePhoto }; + // 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 }; })(); } @@ -920,7 +985,7 @@ Oven (@Model.PricingBreakdown.OvenBatches batch@(Model.PricingBreakdown.OvenBatches != 1 ? "es" : "") - × @Model.PricingBreakdown.OvenCycleMinutes min): + × @Model.PricingBreakdown.OvenCycleMinutes min): @Model.PricingBreakdown.OvenBatchCost.ToString("C") @@ -1225,7 +1290,7 @@ { @if (item.SurfaceAreaSqFt > 0) { @item.SurfaceAreaSqFt.ToString("F2") sqft } - @if (item.SurfaceAreaSqFt > 0 && item.EstimatedMinutes > 0) { · } + @if (item.SurfaceAreaSqFt > 0 && item.EstimatedMinutes > 0) { · } @if (item.EstimatedMinutes > 0) { @item.EstimatedMinutes min } } @@ -1233,7 +1298,7 @@
@if (item.Quantity > 1) { - ×@item.Quantity.ToString("G29") + ×@item.Quantity.ToString("G29") } @item.TotalPrice.ToString("C")
diff --git a/src/PowderCoating.Web/wwwroot/js/job-photos.js b/src/PowderCoating.Web/wwwroot/js/job-photos.js index 632a36b..f0c71bf 100644 --- a/src/PowderCoating.Web/wwwroot/js/job-photos.js +++ b/src/PowderCoating.Web/wwwroot/js/job-photos.js @@ -4,6 +4,7 @@ const jobPhotoModule = { allPhotos: [], currentPhotoIndex: 0, _tagApi: null, + _editTagApi: null, init: function(jobId, tagSuggestions) { this.jobId = jobId; @@ -21,6 +22,17 @@ const jobPhotoModule = { }); }); } + + // Reset to view mode when the view modal closes + const viewModal = document.getElementById('viewPhotoModal'); + if (viewModal) { + viewModal.addEventListener('hidden.bs.modal', () => { + const editPanel = document.getElementById('photoEditPanel'); + if (editPanel && !editPanel.classList.contains('d-none')) { + this.cancelPhotoEdit(); + } + }); + } }, loadJobPhotos: function() { @@ -119,6 +131,11 @@ const jobPhotoModule = { }, navigatePhoto: function(direction) { + // Exit edit mode before navigating to another photo + const editPanel = document.getElementById('photoEditPanel'); + if (editPanel && !editPanel.classList.contains('d-none')) { + this.cancelPhotoEdit(); + } this.currentPhotoIndex += direction; if (this.currentPhotoIndex < 0) this.currentPhotoIndex = this.allPhotos.length - 1; if (this.currentPhotoIndex >= this.allPhotos.length) this.currentPhotoIndex = 0; @@ -212,6 +229,73 @@ const jobPhotoModule = { }); }, + editPhoto: function() { + const photo = this.allPhotos[this.currentPhotoIndex]; + document.getElementById('editPhotoType').value = photo.photoType; + document.getElementById('editPhotoCaption').value = photo.caption || ''; + // Pre-populate tag hidden field so initTagInput picks it up + document.getElementById('editPhotoTagsHidden').value = photo.tags || ''; + this._editTagApi = initTagInput('editPhotoTagsHidden', 'editPhotoTagsContainer', { + suggestions: this._tagSuggestions + }); + document.getElementById('photoDetails').classList.add('d-none'); + document.getElementById('photoEditPanel').classList.remove('d-none'); + document.getElementById('viewModeButtons').classList.add('d-none'); + document.getElementById('editModeButtons').classList.remove('d-none'); + }, + + cancelPhotoEdit: function() { + document.getElementById('photoEditPanel').classList.add('d-none'); + document.getElementById('photoDetails').classList.remove('d-none'); + document.getElementById('editModeButtons').classList.add('d-none'); + document.getElementById('viewModeButtons').classList.remove('d-none'); + }, + + savePhotoEdit: function() { + const photo = this.allPhotos[this.currentPhotoIndex]; + const photoType = parseInt(document.getElementById('editPhotoType').value, 10); + const caption = document.getElementById('editPhotoCaption').value.trim(); + const tags = document.getElementById('editPhotoTagsHidden').value; + const token = document.querySelector('input[name="__RequestVerificationToken"]').value; + + fetch('/Jobs/UpdatePhoto', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'RequestVerificationToken': token + }, + body: JSON.stringify({ + id: photo.id, + caption: caption, + photoType: photoType, + tags: tags, + displayOrder: photo.displayOrder || 0 + }) + }) + .then(r => r.json()) + .then(data => { + if (data.success) { + photo.caption = caption || null; + photo.photoType = photoType; + photo.photoTypeDisplay = this.getPhotoTypeLabel(photoType); + photo.tags = tags || null; + photo.tagsList = tags ? tags.split(',').map(t => t.trim()).filter(t => t) : []; + this.cancelPhotoEdit(); + this.viewPhoto(this.currentPhotoIndex, true); + this.renderPhotoGallery(this.allPhotos); + this.showToast('Photo updated successfully', 'success'); + } else { + this.showToast(data.message || 'Error updating photo', 'danger'); + } + }) + .catch(() => this.showToast('An error occurred while updating', 'danger')); + }, + + getPhotoTypeLabel: function(type) { + const labels = { 0: 'Before', 1: 'Progress', 2: 'After', 3: 'Quality Check', 4: 'Issue', 5: 'Completed' }; + return labels[type] || 'Unknown'; + }, + setupDragDrop: function() { const dropZone = document.getElementById('dropZone'); const fileInput = document.getElementById('photoFile');