Fix time entry workers, powder usage logging, inventory edit, and mojibake

- JobTimeEntry: migrate to UserId/UserDisplayName; make ShopWorkerId nullable
  (migration MigrateTimeEntriesToUserId)
- Log Time modal: populate worker dropdown from Identity users instead of
  ShopWorkers; fix ShopMobile view same issue
- Inventory Ledger: scan-based JobUsage transactions now appear in
  Powder Usage By Job tab (synthesized from InventoryTransaction)
- Inventory Ledger: add Edit button for JobUsage transactions; new
  GetUsageForEdit + EditUsageTransaction endpoints; inventory-ledger.js
- InventoryTransactionRepository: include Job.Customer for ledger queries
- InventoryAiLookupService: handle JSON-LD @graph wrapper (Columbia
  Coatings / WooCommerce+Yoast); add HTML price snippet fallback
- Fix mojibake in 9 views: â†' → →, âœ" → ✓, âš  → ⚠

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-05 21:05:37 -04:00
parent 7fe8bc81c6
commit 03d3f57f7b
20 changed files with 29104 additions and 64 deletions
@@ -0,0 +1,82 @@
// inventory-ledger.js — Edit Usage Record modal logic
async function openUsageEdit(transactionId) {
const modal = new bootstrap.Modal(document.getElementById('editUsageModal'));
const loading = document.getElementById('editUsageLoading');
const form = document.getElementById('editUsageForm');
const saveBtn = document.getElementById('euSaveBtn');
loading.classList.remove('d-none');
form.classList.add('d-none');
saveBtn.disabled = true;
modal.show();
try {
const resp = await fetch(`/Inventory/GetUsageForEdit?id=${transactionId}`);
if (!resp.ok) throw new Error('Failed to load usage record.');
const data = await resp.json();
document.getElementById('euTxnId').value = data.transactionId;
document.getElementById('euItemName').textContent = data.itemName || '—';
document.getElementById('euDate').value = data.transactionDate;
document.getElementById('euNotes').value = data.notes || '';
const jobSel = document.getElementById('euJobId');
jobSel.innerHTML = '<option value="">— No job —</option>';
(data.jobs || []).forEach(j => {
const opt = document.createElement('option');
opt.value = j.id;
opt.textContent = `${j.jobNumber}${j.customerName}`;
if (j.id === data.jobId) opt.selected = true;
jobSel.appendChild(opt);
});
loading.classList.add('d-none');
form.classList.remove('d-none');
saveBtn.disabled = false;
} catch (e) {
loading.innerHTML = `<div class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>${e.message}</div>`;
}
}
document.getElementById('euSaveBtn').addEventListener('click', async () => {
const form = document.getElementById('editUsageForm');
if (!form.reportValidity()) return;
const saveBtn = document.getElementById('euSaveBtn');
const spinner = document.getElementById('euSaveBtnSpinner');
const btnText = document.getElementById('euSaveBtnText');
saveBtn.disabled = true;
spinner.classList.remove('d-none');
btnText.textContent = 'Saving…';
const token = form.querySelector('input[name="__RequestVerificationToken"]')?.value;
const params = new URLSearchParams({
id: document.getElementById('euTxnId').value,
jobId: document.getElementById('euJobId').value,
notes: document.getElementById('euNotes').value,
transactionDate: document.getElementById('euDate').value,
__RequestVerificationToken: token || ''
});
try {
const resp = await fetch('/Inventory/EditUsageTransaction', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString()
});
const result = await resp.json();
if (result.success) {
bootstrap.Modal.getInstance(document.getElementById('editUsageModal')).hide();
location.reload();
} else {
throw new Error('Save failed.');
}
} catch (e) {
saveBtn.disabled = false;
spinner.classList.add('d-none');
btnText.textContent = 'Save Changes';
alert('Error saving changes: ' + e.message);
}
});