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,73 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddGracePeriodDaysSetting : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
IF NOT EXISTS (SELECT 1 FROM [PlatformSettings] WHERE [Key] = N'GracePeriodDays')
BEGIN
INSERT INTO [PlatformSettings] ([Key], [Value], [Label], [Description], [GroupName])
VALUES (N'GracePeriodDays', N'14', N'Grace Period (days)',
N'Number of days a company can continue to log in after their subscription expires before being fully locked out.',
N'Subscriptions');
END
""");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 5, 16, 17, 52, 780, DateTimeKind.Utc).AddTicks(8281));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 5, 16, 17, 52, 780, DateTimeKind.Utc).AddTicks(8287));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 5, 16, 17, 52, 780, DateTimeKind.Utc).AddTicks(8289));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DELETE FROM [PlatformSettings] WHERE [Key] = N'GracePeriodDays'");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 3, 20, 30, 44, 955, DateTimeKind.Utc).AddTicks(5184));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 3, 20, 30, 44, 955, DateTimeKind.Utc).AddTicks(5189));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 3, 20, 30, 44, 955, DateTimeKind.Utc).AddTicks(5191));
}
}
}
@@ -0,0 +1,73 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddGracePeriodAppliesToTrials : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
IF NOT EXISTS (SELECT 1 FROM [PlatformSettings] WHERE [Key] = N'GracePeriodAppliesToTrials')
BEGIN
INSERT INTO [PlatformSettings] ([Key], [Value], [Label], [Description], [GroupName])
VALUES (N'GracePeriodAppliesToTrials', N'false', N'Grace Period Applies to Trials',
N'When false (default), trial accounts (no Stripe subscription) are locked out immediately when their trial expires no grace period. Enable to give trial accounts the same grace period as paid accounts.',
N'Subscriptions');
END
""");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 5, 19, 13, 29, 537, DateTimeKind.Utc).AddTicks(4011));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 5, 19, 13, 29, 537, DateTimeKind.Utc).AddTicks(4019));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 5, 19, 13, 29, 537, DateTimeKind.Utc).AddTicks(4021));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DELETE FROM [PlatformSettings] WHERE [Key] = N'GracePeriodAppliesToTrials'");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 5, 16, 17, 52, 780, DateTimeKind.Utc).AddTicks(8281));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 5, 16, 17, 52, 780, DateTimeKind.Utc).AddTicks(8287));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 5, 16, 17, 52, 780, DateTimeKind.Utc).AddTicks(8289));
}
}
}
@@ -0,0 +1,122 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class MigrateTimeEntriesToUserId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_JobTimeEntries_ShopWorkers_ShopWorkerId",
table: "JobTimeEntries");
migrationBuilder.AlterColumn<int>(
name: "ShopWorkerId",
table: "JobTimeEntries",
type: "int",
nullable: true,
oldClrType: typeof(int),
oldType: "int");
migrationBuilder.AddColumn<string>(
name: "UserDisplayName",
table: "JobTimeEntries",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "UserId",
table: "JobTimeEntries",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 5, 23, 10, 14, 763, DateTimeKind.Utc).AddTicks(8603));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 5, 23, 10, 14, 763, DateTimeKind.Utc).AddTicks(8610));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 5, 23, 10, 14, 763, DateTimeKind.Utc).AddTicks(8612));
migrationBuilder.AddForeignKey(
name: "FK_JobTimeEntries_ShopWorkers_ShopWorkerId",
table: "JobTimeEntries",
column: "ShopWorkerId",
principalTable: "ShopWorkers",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_JobTimeEntries_ShopWorkers_ShopWorkerId",
table: "JobTimeEntries");
migrationBuilder.DropColumn(
name: "UserDisplayName",
table: "JobTimeEntries");
migrationBuilder.DropColumn(
name: "UserId",
table: "JobTimeEntries");
migrationBuilder.AlterColumn<int>(
name: "ShopWorkerId",
table: "JobTimeEntries",
type: "int",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "int",
oldNullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 5, 19, 13, 29, 537, DateTimeKind.Utc).AddTicks(4011));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 5, 19, 13, 29, 537, DateTimeKind.Utc).AddTicks(4019));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 5, 19, 13, 29, 537, DateTimeKind.Utc).AddTicks(4021));
migrationBuilder.AddForeignKey(
name: "FK_JobTimeEntries_ShopWorkers_ShopWorkerId",
table: "JobTimeEntries",
column: "ShopWorkerId",
principalTable: "ShopWorkers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
}
@@ -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>
@@ -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>
}
@@ -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);
}
});