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:
@@ -479,7 +479,7 @@
|
||||
<i class="bi bi-bag-plus-fill me-2 text-muted"></i>Powder in Queue to be Ordered
|
||||
<span class="ms-2 text-muted fw-normal small">@Model.PowderOrdersNeededCount item@(Model.PowderOrdersNeededCount == 1 ? "" : "s")</span>
|
||||
</h5>
|
||||
<small class="text-muted">Grouped by vendor · Mark lines as ordered to remove them</small>
|
||||
<small class="text-muted">Grouped by vendor · Mark lines as ordered to remove them</small>
|
||||
</div>
|
||||
<div class="card-body pt-0 pb-3">
|
||||
@foreach (var vendorGroup in Model.PowderOrdersNeeded)
|
||||
@@ -574,7 +574,7 @@
|
||||
<i class="bi bi-box-arrow-in-down me-2 text-muted"></i>Powder Ordered — Awaiting Receipt
|
||||
<span class="ms-2 text-muted fw-normal small" id="placed-count-label">@Model.PowderOrdersPlacedCount item@(Model.PowderOrdersPlacedCount == 1 ? "" : "s")</span>
|
||||
</h5>
|
||||
<small class="text-muted">Grouped by vendor · Enter lbs received to update inventory</small>
|
||||
<small class="text-muted">Grouped by vendor · Enter lbs received to update inventory</small>
|
||||
</div>
|
||||
<div class="card-body pt-0 pb-3" id="placed-card-body">
|
||||
@if (Model.PowderOrdersPlaced.Any())
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
}
|
||||
@if (Model.ExpiryDate.HasValue)
|
||||
{
|
||||
<span class="ms-2 small">· Expires @Model.ExpiryDate.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MMMM d, yyyy")</span>
|
||||
<span class="ms-2 small">· Expires @Model.ExpiryDate.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MMMM d, yyyy")</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -366,7 +366,7 @@
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(coat.Finish))
|
||||
{
|
||||
<text> · @coat.Finish</text>
|
||||
<text> · @coat.Finish</text>
|
||||
}
|
||||
@if (coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
|
||||
{
|
||||
@@ -493,7 +493,7 @@
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(coat.Finish))
|
||||
{
|
||||
<text> · @coat.Finish</text>
|
||||
<text> · @coat.Finish</text>
|
||||
}
|
||||
@if (coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
|
||||
{
|
||||
@@ -1680,12 +1680,46 @@
|
||||
<p class="mb-1"><strong>Uploaded:</strong> <span id="photoDetailDate"></span></p>
|
||||
<p class="mb-0"><strong>By:</strong> <span id="photoDetailUploader"></span></p>
|
||||
</div>
|
||||
<!-- Edit form (hidden by default) -->
|
||||
<div id="photoEditPanel" class="d-none text-start mt-3">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Photo Type</label>
|
||||
<select class="form-select" id="editPhotoType">
|
||||
<option value="0">Before</option>
|
||||
<option value="1">Progress</option>
|
||||
<option value="2">After</option>
|
||||
<option value="3">Quality Check</option>
|
||||
<option value="4">Issue</option>
|
||||
<option value="5">Completed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Caption / Note</label>
|
||||
<textarea class="form-control" id="editPhotoCaption" rows="2" placeholder="Add a description or note..."></textarea>
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<label class="form-label fw-semibold">Tags <small class="text-muted fw-normal ms-1">— colors, finish, keywords</small></label>
|
||||
<input type="hidden" id="editPhotoTagsHidden" />
|
||||
<div id="editPhotoTagsContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-danger" onclick="jobPhotoModule.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="viewModeButtons" class="d-flex gap-2 w-100 justify-content-end">
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="jobPhotoModule.editPhoto()">
|
||||
<i class="bi bi-pencil me-1"></i>Edit
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger" onclick="jobPhotoModule.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="editModeButtons" class="d-none d-flex gap-2 w-100 justify-content-end">
|
||||
<button type="button" class="btn btn-secondary" onclick="jobPhotoModule.cancelPhotoEdit()">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="jobPhotoModule.savePhotoEdit()">
|
||||
<i class="bi bi-check-lg me-1"></i>Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1747,7 +1781,7 @@
|
||||
<small>@item.Description</small>
|
||||
@if (item.Quantity > 1)
|
||||
{
|
||||
<span class="badge bg-secondary ms-1">×@item.Quantity</span>
|
||||
<span class="badge bg-secondary ms-1">×@item.Quantity</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@@ -1940,7 +1974,7 @@
|
||||
<div class="col-6"><label class="form-label">Width (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
||||
<input type="number" id="rectWidth" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
||||
</div>
|
||||
<small class="text-muted">Formula: L × W ÷ @(ViewBag.UseMetric == true ? "10,000" : "144")</small>
|
||||
<small class="text-muted">Formula: L × W ÷ @(ViewBag.UseMetric == true ? "10,000" : "144")</small>
|
||||
</div>
|
||||
<div id="cylinderInputs" style="display:none">
|
||||
<div class="row g-2">
|
||||
@@ -2666,7 +2700,7 @@
|
||||
pBody.innerHTML = d.hasPowderData
|
||||
? d.powderLines.map(l => `<tr>
|
||||
<td class="text-muted" style="max-width:160px;white-space:normal;">${l.description}${l.isActual ? ' <span class="badge bg-success" style="font-size:0.65rem;">actual</span>' : ''}</td>
|
||||
<td class="text-end text-nowrap">${l.lbs} lbs × ${fmt(l.costPerLb)}/lb</td>
|
||||
<td class="text-end text-nowrap">${l.lbs} lbs × ${fmt(l.costPerLb)}/lb</td>
|
||||
<td class="text-end text-nowrap fw-semibold">${fmt(l.total)}</td></tr>`).join('')
|
||||
: '<tr><td colspan="3" class="text-muted">No powder cost data on coats.</td></tr>';
|
||||
|
||||
@@ -2675,7 +2709,7 @@
|
||||
lBody.innerHTML = d.hasLaborData
|
||||
? d.laborLines.map(l => `<tr>
|
||||
<td class="text-muted">${l.worker}${l.stage ? ' — ' + l.stage : ''}<br/><small>${l.workDate}</small></td>
|
||||
<td class="text-end text-nowrap">${l.hours}h × ${fmt(l.rate)}/hr${l.usingFallback ? ' <span title="Using standard labor rate" class="text-muted">*</span>' : ''}</td>
|
||||
<td class="text-end text-nowrap">${l.hours}h × ${fmt(l.rate)}/hr${l.usingFallback ? ' <span title="Using standard labor rate" class="text-muted">*</span>' : ''}</td>
|
||||
<td class="text-end text-nowrap fw-semibold">${fmt(l.total)}</td></tr>`).join('')
|
||||
: '<tr><td colspan="3" class="text-muted">No time entries logged yet.</td></tr>';
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
@foreach (var item in job.Items)
|
||||
{
|
||||
<li class="d-flex align-items-start gap-2 mb-1">
|
||||
<span class="badge bg-secondary">×@item.Quantity</span>
|
||||
<span class="badge bg-secondary">×@item.Quantity</span>
|
||||
<span class="small">@item.Description</span>
|
||||
</li>
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
× @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> · </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">×@item.Quantity.ToString("G29")</span>
|
||||
}
|
||||
<span class="fw-semibold">@item.TotalPrice.ToString("C")</span>
|
||||
</div>
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user