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
@@ -154,7 +154,7 @@
</div>
</div>
<div class="mobile-card-footer">
<span class="btn btn-sm btn-outline-primary">Edit →</span>
<span class="btn btn-sm btn-outline-primary">Edit </span>
</div>
</a>
}
@@ -179,7 +179,7 @@
</div>
</div>
<div class="mobile-card-footer">
<span class="btn btn-sm btn-outline-primary">View →</span>
<span class="btn btn-sm btn-outline-primary">View </span>
</div>
</a>
}
@@ -301,7 +301,7 @@
}
</div>
<div class="mobile-card-footer">
<span class="btn btn-sm btn-outline-primary">Edit →</span>
<span class="btn btn-sm btn-outline-primary">Edit </span>
</div>
</a>
}
@@ -274,7 +274,7 @@
}
</div>
<div class="mobile-card-footer">
<span class="btn btn-sm btn-outline-primary">View →</span>
<span class="btn btn-sm btn-outline-primary">View </span>
</div>
</a>
}
@@ -155,6 +155,7 @@
<th class="text-end">Balance After</th>
<th>Reference</th>
<th>Notes</th>
<th></th>
</tr>
</thead>
<tbody>
@@ -203,6 +204,16 @@
}
</td>
<td><small class="text-muted">@t.Notes</small></td>
<td>
@if (t.TransactionType == "JobUsage")
{
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1"
title="Edit usage record"
onclick="openUsageEdit(@t.Id)">
<i class="bi bi-pencil"></i>
</button>
}
</td>
</tr>
}
</tbody>
@@ -241,6 +252,7 @@
<th class="text-end">Actual (lbs)</th>
<th class="text-end">Variance</th>
<th>Notes</th>
<th></th>
</tr>
</thead>
<tbody>
@@ -250,10 +262,17 @@
<tr>
<td class="text-nowrap">@u.RecordedAt.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd/yyyy")</td>
<td class="text-nowrap">
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@u.JobId"
class="text-decoration-none fw-semibold">
@u.JobNumber
</a>
@if (u.JobId > 0)
{
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@u.JobId"
class="text-decoration-none fw-semibold">
@u.JobNumber
</a>
}
else
{
<span class="text-muted fst-italic">No job assigned</span>
}
</td>
<td>@u.CustomerName</td>
@if (!Model.InventoryItemId.HasValue)
@@ -279,6 +298,16 @@
@(variance > 0 ? "+" : "")@variance.ToString("N3")
</td>
<td><small class="text-muted">@u.Notes</small></td>
<td>
@if (u.SourceTransactionId.HasValue)
{
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1"
title="Edit usage record"
onclick="openUsageEdit(@u.SourceTransactionId.Value)">
<i class="bi bi-pencil"></i>
</button>
}
</td>
</tr>
}
</tbody>
@@ -291,6 +320,7 @@
@(Model.PowderUsageLogs.Sum(u => u.VarianceLbs) > 0 ? "+" : "")@Model.PowderUsageLogs.Sum(u => u.VarianceLbs).ToString("N3")
</td>
<td></td>
<td></td>
</tr>
</tfoot>
</table>
@@ -302,14 +332,63 @@
}
</div>
@* ── Edit Usage Modal ─────────────────────────────────────────────── *@
<div class="modal fade" id="editUsageModal" tabindex="-1" aria-labelledby="editUsageModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editUsageModalLabel">
<i class="bi bi-pencil me-2"></i>Edit Usage Record
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="editUsageLoading" class="text-center py-4">
<div class="spinner-border spinner-border-sm me-2"></div>Loading…
</div>
<form id="editUsageForm" class="d-none">
@Html.AntiForgeryToken()
<input type="hidden" id="euTxnId" name="id" />
<div class="mb-3">
<label class="form-label fw-semibold">Powder Item</label>
<p id="euItemName" class="form-control-plaintext text-muted"></p>
</div>
<div class="mb-3">
<label for="euJobId" class="form-label fw-semibold">Job <span class="text-muted fw-normal">(optional)</span></label>
<select id="euJobId" name="jobId" class="form-select">
<option value="">— No job —</option>
</select>
<div class="form-text">Select the job this powder was used on.</div>
</div>
<div class="mb-3">
<label for="euDate" class="form-label fw-semibold">Date / Time</label>
<input type="datetime-local" id="euDate" name="transactionDate" class="form-control" required />
</div>
<div class="mb-3">
<label for="euNotes" class="form-label fw-semibold">Notes</label>
<textarea id="euNotes" name="notes" class="form-control" rows="2" maxlength="500"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="euSaveBtn" disabled>
<span id="euSaveBtnText">Save Changes</span>
<span id="euSaveBtnSpinner" class="spinner-border spinner-border-sm ms-1 d-none"></span>
</button>
</div>
</div>
</div>
</div>
@section Scripts {
<script src="~/js/inventory-ledger.js" asp-append-version="true"></script>
<script>
function switchTab(tab) {
document.getElementById('tab-transactions').classList.toggle('d-none', tab !== 'transactions');
document.getElementById('tab-usage').classList.toggle('d-none', tab !== 'usage');
document.querySelectorAll('#ledgerTabs .nav-link').forEach(el => el.classList.remove('active'));
event.currentTarget.classList.add('active');
// Update hidden tab field in filter form
document.querySelector('input[name="tab"]').value = tab;
}
</script>
@@ -1,4 +1,4 @@
@model PowderCoating.Application.DTOs.Job.JobDto
@model PowderCoating.Application.DTOs.Job.JobDto
@{
ViewData["Title"] = $"Job {Model.JobNumber}";
@@ -730,7 +730,6 @@
<thead class="table-light">
<tr>
<th>Worker</th>
<th>Role</th>
<th>Date</th>
<th class="text-end">Hours</th>
<th>Stage</th>
@@ -1311,7 +1310,7 @@
<a asp-action="Intake" asp-route-id="@Model.Id"
class="btn @(Model.IntakeDate.HasValue ? "btn-outline-secondary" : "btn-outline-info")"
title="@(Model.IntakeDate.HasValue ? "Update part intake record" : "Check in parts for this job")">
<i class="bi bi-box-seam me-2"></i>@(Model.IntakeDate.HasValue ? "Intake ✓" : "Intake")
<i class=bi bi-box-seam me-2></i>@(Model.IntakeDate.HasValue ? "Intake " : "Intake")
</a>
}
@{
@@ -2198,7 +2197,7 @@
<option value="">— Select worker —</option>
@foreach (var w in (ViewBag.ShopWorkers as IEnumerable<dynamic> ?? []))
{
<option value="@w.Id">@w.Name (@w.Role)</option>
<option value="@w.Id">@w.Name</option>
}
</select>
</div>
@@ -2682,8 +2681,8 @@
// Notes
const notes = [];
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.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('');
@@ -2748,7 +2747,6 @@
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="fw-semibold">${esc(e.workerName)}</td>
<td class="text-muted small">${esc(e.workerRole)}</td>
<td class="small">${d}</td>
<td class="text-end fw-semibold">${e.hoursWorked.toFixed(2)}</td>
<td class="small">${e.stage ? `<span class="badge bg-secondary-subtle text-secondary">${esc(e.stage)}</span>` : '<span class="text-muted">—</span>'}</td>
@@ -2786,7 +2784,7 @@
if (!e) return;
document.getElementById('timeEntryModalTitle').textContent = 'Edit Time Entry';
document.getElementById('teEntryId').value = e.id;
document.getElementById('teWorkerId').value = e.shopWorkerId;
document.getElementById('teWorkerId').value = e.userId ?? '';
document.getElementById('teWorkDate').value = new Date(e.workDate).toISOString().slice(0, 10);
document.getElementById('teHoursWorked').value = e.hoursWorked;
document.getElementById('teStage').value = e.stage ?? '';
@@ -2813,8 +2811,8 @@
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
const url = id > 0 ? '/Jobs/UpdateTimeEntry' : '/Jobs/AddTimeEntry';
const body = id > 0
? { id, shopWorkerId: workerId, workDate, hoursWorked: hours, stage: stage || null, notes: notes || null }
: { jobId: jid, shopWorkerId: workerId, workDate, hoursWorked: hours, stage: stage || null, notes: notes || null };
? { id, userId: workerId, workDate, hoursWorked: hours, stage: stage || null, notes: notes || null }
: { jobId: jid, userId: workerId, workDate, hoursWorked: hours, stage: stage || null, notes: notes || null };
try {
const r = await fetch(url, {
@@ -1,9 +1,7 @@
@model List<PowderCoating.Application.DTOs.Job.JobDailyPriorityDto>
@using PowderCoating.Core.Entities
@{
Layout = null;
var workers = ViewBag.Workers as List<ShopWorker> ?? new();
var workers = (ViewBag.Workers as IEnumerable<dynamic>) ?? Array.Empty<dynamic>();
var currentWorkerId = ViewBag.CurrentWorkerId as string;
var allStatuses = ViewBag.AllStatuses as List<JobStatusLookup> ?? new();
var activeCount = Model.Count;
@@ -202,7 +202,7 @@
</div>
</div>
<div class="mobile-card-footer">
<span class="btn btn-sm btn-outline-secondary">View →</span>
<span class="btn btn-sm btn-outline-secondary">View </span>
</div>
</a>
}
@@ -20,7 +20,7 @@
<a tabindex="0" class="help-icon ms-1" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Quote Statuses"
data-bs-content="&lt;strong&gt;Draft&lt;/strong&gt; — saved but not yet sent. Edit freely.&lt;br&gt;&lt;strong&gt;Sent&lt;/strong&gt; — delivered to the customer, awaiting response.&lt;br&gt;&lt;strong&gt;Approved&lt;/strong&gt; — customer accepted. You can convert this to a Job.&lt;br&gt;&lt;strong&gt;Rejected&lt;/strong&gt; — customer declined.&lt;br&gt;&lt;strong&gt;Expired&lt;/strong&gt; — validity period has passed. Edit to extend it.&lt;br&gt;&lt;strong&gt;Converted&lt;/strong&gt; — a job has been created from this quote.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-statuses' target='_blank'&gt;Learn more →&lt;/a&gt;">
data-bs-content="&lt;strong&gt;Draft&lt;/strong&gt; — saved but not yet sent. Edit freely.&lt;br&gt;&lt;strong&gt;Sent&lt;/strong&gt; — delivered to the customer, awaiting response.&lt;br&gt;&lt;strong&gt;Approved&lt;/strong&gt; — customer accepted. You can convert this to a Job.&lt;br&gt;&lt;strong&gt;Rejected&lt;/strong&gt; — customer declined.&lt;br&gt;&lt;strong&gt;Expired&lt;/strong&gt; — validity period has passed. Edit to extend it.&lt;br&gt;&lt;strong&gt;Converted&lt;/strong&gt; — a job has been created from this quote.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-statuses' target='_blank'&gt;Learn more &lt;/a&gt;">
<i class="bi bi-question-circle"></i>
</a>
</p>
@@ -273,7 +273,7 @@
</div>
</div>
<div class="mobile-card-footer">
<span class="btn btn-sm btn-outline-primary">Manage →</span>
<span class="btn btn-sm btn-outline-primary">Manage </span>
</div>
</a>
}
@@ -221,7 +221,7 @@
}
</div>
<div class="mobile-card-footer">
<span class="btn btn-sm btn-outline-secondary">View Details →</span>
<span class="btn btn-sm btn-outline-secondary">View Details </span>
</div>
</div>
}