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
@@ -1610,6 +1610,62 @@ public class InventoryController : Controller
if (inventoryItemId.HasValue)
selectedItem = await _unitOfWork.InventoryItems.GetByIdAsync(inventoryItemId.Value);
// Synthesize powder-usage rows for scan-based JobUsage transactions not already linked to a PowderUsageLog
var linkedTxIds = usageLogs
.Where(u => u.InventoryTransactionId.HasValue)
.Select(u => u.InventoryTransactionId!.Value)
.ToHashSet();
var powderUsageDtos = usageLogs.Select(u => new PowderUsageLogDto
{
Id = u.Id,
JobId = u.JobId,
JobNumber = u.Job?.JobNumber ?? string.Empty,
CustomerName = u.Job?.Customer?.CompanyName ?? $"{u.Job?.Customer?.ContactFirstName} {u.Job?.Customer?.ContactLastName}".Trim(),
InventoryItemId = u.InventoryItemId,
ItemName = u.InventoryItem?.Name,
SKU = u.InventoryItem?.SKU,
CoatColor = u.JobItemCoat?.ColorName,
ActualLbsUsed = u.ActualLbsUsed,
EstimatedLbs = u.EstimatedLbs,
VarianceLbs = u.VarianceLbs,
RecordedAt = u.RecordedAt,
Notes = u.Notes
}).ToList();
// Scan-based JobUsage entries have a JobId on the transaction but no PowderUsageLog record;
// surface them in the "Powder Usage By Job" tab so they aren't invisible.
powderUsageDtos.AddRange(transactions
.Where(t => t.TransactionType == InventoryTransactionType.JobUsage
&& !linkedTxIds.Contains(t.Id)
&& (t.JobId.HasValue || (t.Reference != null && jobRefLookup.ContainsKey(t.Reference))))
.Select(t =>
{
var jobId = t.JobId ?? (t.Reference != null && jobRefLookup.TryGetValue(t.Reference, out var r) ? r.Id : 0);
var jobNumber = t.Job?.JobNumber ?? (t.Reference != null && jobRefLookup.ContainsKey(t.Reference) ? t.Reference : string.Empty);
var cust = t.Job?.Customer;
var custName = cust?.CompanyName ?? $"{cust?.ContactFirstName} {cust?.ContactLastName}".Trim();
return new PowderUsageLogDto
{
Id = 0,
SourceTransactionId = t.Id,
JobId = jobId,
JobNumber = jobNumber,
CustomerName = string.IsNullOrWhiteSpace(custName) ? null : custName,
InventoryItemId = t.InventoryItemId,
ItemName = t.InventoryItem?.Name,
SKU = t.InventoryItem?.SKU,
CoatColor = null,
ActualLbsUsed = Math.Abs(t.Quantity),
EstimatedLbs = 0,
VarianceLbs = 0,
RecordedAt = t.TransactionDate,
Notes = t.Notes
};
}));
powderUsageDtos = [.. powderUsageDtos.OrderByDescending(u => u.RecordedAt)];
var vm = new InventoryLedgerViewModel
{
InventoryItemId = inventoryItemId,
@@ -1638,22 +1694,7 @@ public class InventoryController : Controller
JobId = t.JobId ?? (t.Reference != null && jobRefLookup.TryGetValue(t.Reference, out var resolved) ? resolved.Id : null),
JobNumber = t.Job?.JobNumber ?? (t.Reference != null && jobRefLookup.ContainsKey(t.Reference) ? t.Reference : null)
}).ToList(),
PowderUsageLogs = usageLogs.Select(u => new PowderUsageLogDto
{
Id = u.Id,
JobId = u.JobId,
JobNumber = u.Job?.JobNumber ?? string.Empty,
CustomerName = u.Job?.Customer?.CompanyName ?? $"{u.Job?.Customer?.ContactFirstName} {u.Job?.Customer?.ContactLastName}".Trim(),
InventoryItemId = u.InventoryItemId,
ItemName = u.InventoryItem?.Name,
SKU = u.InventoryItem?.SKU,
CoatColor = u.JobItemCoat?.ColorName,
ActualLbsUsed = u.ActualLbsUsed,
EstimatedLbs = u.EstimatedLbs,
VarianceLbs = u.VarianceLbs,
RecordedAt = u.RecordedAt,
Notes = u.Notes
}).ToList(),
PowderUsageLogs = powderUsageDtos,
TotalPurchased = transactions
.Where(t => t.TransactionType == InventoryTransactionType.Purchase || t.TransactionType == InventoryTransactionType.Initial)
.Sum(t => t.Quantity),
@@ -1667,6 +1708,85 @@ public class InventoryController : Controller
return View(vm);
}
/// <summary>
/// Returns the current values of a JobUsage InventoryTransaction plus a list of active
/// jobs so the edit modal can be pre-populated without a full page reload.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetUsageForEdit(int id)
{
var txn = await _unitOfWork.InventoryTransactions.GetByIdAsync(id, false,
t => t.Job, t => t.InventoryItem);
if (txn == null) return NotFound();
if (txn.TransactionType != InventoryTransactionType.JobUsage)
return BadRequest("Only JobUsage transactions can be edited here.");
var allJobs = await _unitOfWork.Jobs.FindAsync(
j => !j.JobStatus.IsTerminalStatus,
false,
j => j.Customer,
j => j.JobStatus);
var jobs = allJobs
.OrderByDescending(j => j.CreatedAt)
.Take(200)
.Select(j => new ScanJobOption
{
Id = j.Id,
JobNumber = j.JobNumber,
CustomerName = j.Customer != null
? (j.Customer.CompanyName ?? $"{j.Customer.ContactFirstName} {j.Customer.ContactLastName}".Trim())
: "No Customer"
})
.ToList();
return Json(new
{
transactionId = txn.Id,
jobId = txn.JobId,
notes = txn.Notes,
transactionDate = txn.TransactionDate.ToString("yyyy-MM-ddTHH:mm"),
itemName = txn.InventoryItem?.Name,
jobs
});
}
/// <summary>
/// Saves edits to a JobUsage InventoryTransaction's job assignment, notes, and date.
/// Quantity and balance are not changed.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> EditUsageTransaction(int id, int? jobId, string? notes, DateTime transactionDate)
{
var txn = await _unitOfWork.InventoryTransactions.GetByIdAsync(id);
if (txn == null) return NotFound();
if (txn.TransactionType != InventoryTransactionType.JobUsage)
return BadRequest();
if (jobId.HasValue)
{
var job = await _unitOfWork.Jobs.GetByIdAsync(jobId.Value);
txn.JobId = jobId.Value;
txn.Reference = job?.JobNumber;
}
else
{
txn.JobId = null;
txn.Reference = null;
}
txn.Notes = notes?.Trim();
txn.TransactionDate = transactionDate.Kind == DateTimeKind.Utc
? transactionDate : DateTime.SpecifyKind(transactionDate, DateTimeKind.Utc);
txn.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.InventoryTransactions.UpdateAsync(txn);
await _unitOfWork.SaveChangesAsync();
return Json(new { success = true });
}
}
/// <summary>Helper projection used by the Scan action for job picker data.</summary>
@@ -404,10 +404,12 @@ public class JobsController : Controller
// Workers dropdown for inline assignment
await PopulateWorkersDropdown();
// Shop workers for time entry
var shopWorkers = (await _unitOfWork.ShopWorkers.FindAsync(w => w.IsActive, false))
.OrderBy(w => w.Name).ToList();
ViewBag.ShopWorkers = shopWorkers.Select(w => new { w.Id, w.Name, Role = System.Text.RegularExpressions.Regex.Replace(w.Role.ToString(), "([a-z])([A-Z])", "$1 $2") }).ToList();
// Company users for time entry worker dropdown
var companyUsers = await _userManager.Users
.Where(u => u.CompanyId == job.CompanyId && u.IsActive)
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
.ToListAsync();
ViewBag.ShopWorkers = companyUsers.Select(u => new { Id = u.Id, Name = u.FullName }).ToList();
// Populate Edit Items wizard data (inline modal on Details page)
var wizardCosts = await _pricingService.GetOperatingCostsAsync(job.CompanyId);
@@ -2127,9 +2129,12 @@ public class JobsController : Controller
.ThenBy(j => j.JobNumber)
.ToList();
// Workers for filter dropdown
var workers = await _unitOfWork.ShopWorkers.FindAsync(w => w.IsActive && !w.IsDeleted);
ViewBag.Workers = workers.OrderBy(w => w.Name).ToList();
// Company users for worker filter chips
var mobileWorkers = await _userManager.Users
.Where(u => u.CompanyId == companyId.Value && u.IsActive)
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
.ToListAsync();
ViewBag.Workers = mobileWorkers.Select(u => new { Id = u.Id, Name = u.FullName }).ToList();
ViewBag.CurrentWorkerId = workerId;
ViewBag.AllStatuses = allStatuses;
@@ -3352,13 +3357,13 @@ public class JobsController : Controller
{
var entries = await _unitOfWork.JobTimeEntries.FindAsync(
e => e.JobId == jobId, false,
e => e.Worker);
e => e.Worker); // Worker nav loaded for display of legacy entries that pre-date user migration
var dtos = _mapper.Map<List<JobTimeEntryDto>>(entries.OrderByDescending(e => e.WorkDate).ToList());
return Json(dtos);
}
/// <summary>
/// Adds a time entry for a shop worker on a specific job.
/// Adds a time entry for a company user on a specific job.
/// Validates hours are in the reasonable range (0.124) before saving.
/// Returns the new entry as JSON so the UI can append it to the list without a reload.
/// </summary>
@@ -3371,13 +3376,14 @@ public class JobsController : Controller
var job = await _unitOfWork.Jobs.GetByIdAsync(dto.JobId);
if (job == null) return NotFound();
var worker = await _unitOfWork.ShopWorkers.GetByIdAsync(dto.ShopWorkerId);
if (worker == null) return BadRequest(new { error = "Worker not found." });
var user = await _userManager.FindByIdAsync(dto.UserId);
if (user == null) return BadRequest(new { error = "Worker not found." });
var entry = new JobTimeEntry
{
JobId = dto.JobId,
ShopWorkerId = dto.ShopWorkerId,
UserId = dto.UserId,
UserDisplayName = user.FullName,
WorkDate = dto.WorkDate.Date,
HoursWorked = Math.Round(dto.HoursWorked, 2),
Stage = string.IsNullOrWhiteSpace(dto.Stage) ? null : dto.Stage.Trim(),
@@ -3388,9 +3394,7 @@ public class JobsController : Controller
await _unitOfWork.JobTimeEntries.AddAsync(entry);
await _unitOfWork.CompleteAsync();
// Reload with worker navigation for the response
var saved = await _unitOfWork.JobTimeEntries.GetByIdAsync(entry.Id, false, e => e.Worker);
return Json(_mapper.Map<JobTimeEntryDto>(saved));
return Json(_mapper.Map<JobTimeEntryDto>(entry));
}
/// <summary>Updates an existing time entry's hours, stage, and notes in place.</summary>
@@ -3400,13 +3404,14 @@ public class JobsController : Controller
if (dto.HoursWorked <= 0 || dto.HoursWorked > 24)
return BadRequest(new { error = "Hours must be between 0.1 and 24." });
var entry = await _unitOfWork.JobTimeEntries.GetByIdAsync(dto.Id, false, e => e.Worker);
var entry = await _unitOfWork.JobTimeEntries.GetByIdAsync(dto.Id);
if (entry == null) return NotFound();
var worker = await _unitOfWork.ShopWorkers.GetByIdAsync(dto.ShopWorkerId);
if (worker == null) return BadRequest(new { error = "Worker not found." });
var user = await _userManager.FindByIdAsync(dto.UserId);
if (user == null) return BadRequest(new { error = "Worker not found." });
entry.ShopWorkerId = dto.ShopWorkerId;
entry.UserId = dto.UserId;
entry.UserDisplayName = user.FullName;
entry.WorkDate = dto.WorkDate.Date;
entry.HoursWorked = Math.Round(dto.HoursWorked, 2);
entry.Stage = string.IsNullOrWhiteSpace(dto.Stage) ? null : dto.Stage.Trim();
@@ -3416,9 +3421,7 @@ public class JobsController : Controller
await _unitOfWork.JobTimeEntries.UpdateAsync(entry);
await _unitOfWork.CompleteAsync();
// Reload for response
var saved = await _unitOfWork.JobTimeEntries.GetByIdAsync(entry.Id, false, e => e.Worker);
return Json(_mapper.Map<JobTimeEntryDto>(saved));
return Json(_mapper.Map<JobTimeEntryDto>(entry));
}
/// <summary>Soft-deletes a time entry. The hours are removed from the job's running total.</summary>