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 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 12:27:37 -04:00
parent 74414c6c71
commit 2e73cfab54
7 changed files with 226 additions and 38 deletions
@@ -764,12 +764,31 @@
</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">
<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 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>
@@ -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 };
})();
</script>
}
@@ -920,7 +985,7 @@
<span>
<i class="bi bi-fire me-1"></i>Oven
(@Model.PricingBreakdown.OvenBatches batch@(Model.PricingBreakdown.OvenBatches != 1 ? "es" : "")
× @Model.PricingBreakdown.OvenCycleMinutes min):
&times; @Model.PricingBreakdown.OvenCycleMinutes min):
</span>
<strong>@Model.PricingBreakdown.OvenBatchCost.ToString("C")</strong>
</div>
@@ -1225,7 +1290,7 @@
{
<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> · </text> }
@if (item.SurfaceAreaSqFt > 0 && item.EstimatedMinutes > 0) { <text> &middot; </text> }
@if (item.EstimatedMinutes > 0) { <text>@item.EstimatedMinutes min</text> }
</span>
}
@@ -1233,7 +1298,7 @@
<div class="text-end">
@if (item.Quantity > 1)
{
<span class="text-muted me-1">×@item.Quantity.ToString("G29")</span>
<span class="text-muted me-1">&times;@item.Quantity.ToString("G29")</span>
}
<span class="fw-semibold">@item.TotalPrice.ToString("C")</span>
</div>