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:
@@ -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.1–24) 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>
|
||||
|
||||
@@ -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="<strong>Draft</strong> — saved but not yet sent. Edit freely.<br><strong>Sent</strong> — delivered to the customer, awaiting response.<br><strong>Approved</strong> — customer accepted. You can convert this to a Job.<br><strong>Rejected</strong> — customer declined.<br><strong>Expired</strong> — validity period has passed. Edit to extend it.<br><strong>Converted</strong> — a job has been created from this quote.<br><br><a href='/Help/Quotes#quote-statuses' target='_blank'>Learn more →</a>">
|
||||
data-bs-content="<strong>Draft</strong> — saved but not yet sent. Edit freely.<br><strong>Sent</strong> — delivered to the customer, awaiting response.<br><strong>Approved</strong> — customer accepted. You can convert this to a Job.<br><strong>Rejected</strong> — customer declined.<br><strong>Expired</strong> — validity period has passed. Edit to extend it.<br><strong>Converted</strong> — a job has been created from this quote.<br><br><a href='/Help/Quotes#quote-statuses' target='_blank'>Learn more →</a>">
|
||||
<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>
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user