Files
PowderCoatingLogix/src/PowderCoating.Web/Views/Inventory/Ledger.cshtml
T
spouliot c45a6826bd Fix time entry 500 and inventory edit pencil visibility
- Remove parseInt() from time entry worker select — GUIDs were destroyed
  to NaN → sent as null → FindByIdAsync(null) threw 500
- Ledger pencil: also show for Adjustment rows (no PO) so scan-without-job
  entries get an edit button, not just JobUsage rows
- InventoryController: always write JobUsage type for scan-based logs;
  accept Adjustment in edit endpoints; promote Adjustment→JobUsage when
  a job is assigned via edit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 21:46:05 -04:00

396 lines
21 KiB
Plaintext

@model PowderCoating.Application.DTOs.Inventory.InventoryLedgerViewModel
@using PowderCoating.Application.DTOs.Inventory
@{
ViewData["Title"] = "Inventory Activity";
ViewData["PageIcon"] = "bi-clock-history";
var activeTab = Context.Request.Query["tab"].ToString();
if (string.IsNullOrEmpty(activeTab)) activeTab = "transactions";
}
@section Styles {
<style>
.badge-txn-Purchase { background: #198754; color: #fff; }
.badge-txn-Initial { background: #0d6efd; color: #fff; }
.badge-txn-Adjustment { background: #6f42c1; color: #fff; }
.badge-txn-JobUsage { background: #dc3545; color: #fff; }
.badge-txn-Sale { background: #fd7e14; color: #fff; }
.badge-txn-Waste { background: #6c757d; color: #fff; }
.badge-txn-Return { background: #20c997; color: #fff; }
.badge-txn-Transfer { background: #0dcaf0; color: #000; }
.qty-positive { color: #198754; font-weight: 600; }
.qty-negative { color: #dc3545; font-weight: 600; }
.variance-over { color: #dc3545; }
.variance-under { color: #198754; }
.filter-bar { background: var(--bs-tertiary-bg); border: 1px solid var(--bs-border-color); border-radius: .5rem; padding: 1rem 1.25rem; margin-bottom: 1.5rem; }
.stat-pill { background: var(--bs-tertiary-bg); border: 1px solid var(--bs-border-color); border-radius: .5rem; padding: .5rem 1rem; text-align: center; min-width: 130px; }
.stat-pill .stat-val { font-size: 1.25rem; font-weight: 700; }
.stat-pill .stat-lbl { font-size: .75rem; color: var(--bs-secondary-color); }
</style>
}
<div class="d-flex align-items-center justify-content-between mb-3">
<div>
@if (!string.IsNullOrEmpty(Model.SelectedItemName))
{
<div class="text-muted small">
<i class="bi bi-box-seam me-1"></i>@Model.SelectedItemSku — @Model.SelectedItemName
</div>
}
</div>
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Back to Inventory
</a>
</div>
@* ── Filter Bar ─────────────────────────────────────────────── *@
<form method="get" class="filter-bar">
<input type="hidden" name="tab" value="@activeTab" />
<div class="d-flex flex-wrap gap-2 align-items-end">
<div>
<label class="form-label mb-1 small fw-semibold">Item</label>
<select name="inventoryItemId" class="form-select form-select-sm" style="min-width:220px">
<option value="">All Items</option>
@foreach (var item in Model.AllItems)
{
<option value="@item.Id" selected="@(Model.InventoryItemId == item.Id)">
@item.SKU — @item.Name
</option>
}
</select>
</div>
<div>
<label class="form-label mb-1 small fw-semibold">From</label>
<input type="date" name="dateFrom" class="form-control form-control-sm"
value="@Model.DateFrom?.ToString("yyyy-MM-dd")" style="width:140px" />
</div>
<div>
<label class="form-label mb-1 small fw-semibold">To</label>
<input type="date" name="dateTo" class="form-control form-control-sm"
value="@Model.DateTo?.ToString("yyyy-MM-dd")" style="width:140px" />
</div>
<div>
<label class="form-label mb-1 small fw-semibold">Type</label>
<select name="typeFilter" class="form-select form-select-sm" style="min-width:140px">
<option value="">All Types</option>
@foreach (var t in new[] { "Purchase","Initial","Adjustment","JobUsage","Sale","Return","Waste","Transfer" })
{
<option value="@t" selected="@(Model.TypeFilter == t)">@t</option>
}
</select>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-search"></i></button>
<a asp-action="Ledger" class="btn btn-outline-secondary btn-sm">Clear</a>
</div>
</div>
</form>
@* ── Summary Pills ───────────────────────────────────────────── *@
<div class="d-flex flex-wrap gap-3 mb-3">
<div class="stat-pill">
<div class="stat-val text-success">@Model.TotalPurchased.ToString("N2")</div>
<div class="stat-lbl">lbs Received</div>
</div>
<div class="stat-pill">
<div class="stat-val text-danger">@Model.TotalUsed.ToString("N2")</div>
<div class="stat-lbl">lbs Used / Sold</div>
</div>
<div class="stat-pill">
<div class="stat-val @(Model.TotalAdjusted >= 0 ? "text-primary" : "text-warning")">
@(Model.TotalAdjusted >= 0 ? "+" : "")@Model.TotalAdjusted.ToString("N2")
</div>
<div class="stat-lbl">lbs Adjusted</div>
</div>
<div class="stat-pill">
<div class="stat-val">@Model.Transactions.Count</div>
<div class="stat-lbl">Transactions</div>
</div>
<div class="stat-pill">
<div class="stat-val">@Model.PowderUsageLogs.Count</div>
<div class="stat-lbl">Usage Records</div>
</div>
</div>
@* ── Tabs ─────────────────────────────────────────────────────── *@
<ul class="nav nav-tabs mb-3" id="ledgerTabs">
<li class="nav-item">
<button class="nav-link @(activeTab == "transactions" ? "active" : "")"
onclick="switchTab('transactions')">
<i class="bi bi-list-ul me-1"></i>Stock Transactions
<span class="badge bg-secondary ms-1">@Model.Transactions.Count</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "usage" ? "active" : "")"
onclick="switchTab('usage')">
<i class="bi bi-fire me-1"></i>Powder Usage by Job
<span class="badge bg-secondary ms-1">@Model.PowderUsageLogs.Count</span>
</button>
</li>
</ul>
@* ── Transactions Tab ─────────────────────────────────────────── *@
<div id="tab-transactions" class="@(activeTab != "usage" ? "" : "d-none")">
@if (!Model.Transactions.Any())
{
<div class="alert alert-info alert-permanent">
<i class="bi bi-info-circle me-2"></i>No transactions found for the selected filters.
</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm table-hover align-middle">
<thead class="table-light">
<tr>
<th>Date</th>
@if (!Model.InventoryItemId.HasValue)
{
<th>Item</th>
}
<th>Type</th>
<th class="text-end">Qty</th>
<th class="text-end">Unit Cost</th>
<th class="text-end">Total</th>
<th class="text-end">Balance After</th>
<th>Reference</th>
<th>Notes</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var t in Model.Transactions)
{
<tr>
<td class="text-nowrap">@t.TransactionDate.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd/yyyy h:mm tt")</td>
@if (!Model.InventoryItemId.HasValue)
{
<td>
<a asp-action="Ledger" asp-route-inventoryItemId="@t.InventoryItemId" class="text-decoration-none">
<span class="fw-semibold">@t.ItemName</span>
<br /><small class="text-muted">@t.SKU</small>
</a>
</td>
}
<td>
<span class="badge badge-txn-@t.TransactionType">@t.TransactionType</span>
</td>
<td class="text-end @(t.Quantity >= 0 ? "qty-positive" : "qty-negative")">
@(t.Quantity >= 0 ? "+" : "")@t.Quantity.ToString("N2")
</td>
<td class="text-end">@t.UnitCost.ToString("C")</td>
<td class="text-end">@t.TotalCost.ToString("C")</td>
<td class="text-end fw-semibold">@t.BalanceAfter.ToString("N2")</td>
<td class="text-nowrap">
@if (t.PurchaseOrderId.HasValue)
{
<a asp-controller="PurchaseOrders" asp-action="Details" asp-route-id="@t.PurchaseOrderId">
@(t.PurchaseOrderNumber ?? $"PO #{t.PurchaseOrderId}")
</a>
}
else if (t.JobId.HasValue)
{
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@t.JobId" class="text-decoration-none fw-semibold">
@(t.JobNumber ?? t.Reference ?? $"Job #{t.JobId}")
</a>
}
else if (!string.IsNullOrEmpty(t.Reference))
{
@t.Reference
}
else
{
<span class="text-muted">—</span>
}
</td>
<td><small class="text-muted">@t.Notes</small></td>
<td>
@if (t.TransactionType == "JobUsage" || (t.TransactionType == "Adjustment" && t.PurchaseOrderId == null))
{
<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>
</table>
</div>
@if (Model.Transactions.Count == 500)
{
<p class="text-muted small">Showing the 500 most recent transactions. Use filters to narrow results.</p>
}
}
</div>
@* ── Usage Tab ────────────────────────────────────────────────── *@
<div id="tab-usage" class="@(activeTab == "usage" ? "" : "d-none")">
@if (!Model.PowderUsageLogs.Any())
{
<div class="alert alert-info alert-permanent">
<i class="bi bi-info-circle me-2"></i>No powder usage records found for the selected filters.
</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm table-hover align-middle">
<thead class="table-light">
<tr>
<th>Date</th>
<th>Job</th>
<th>Customer</th>
@if (!Model.InventoryItemId.HasValue)
{
<th>Powder</th>
}
<th>Color / Coat</th>
<th class="text-end">Estimated (lbs)</th>
<th class="text-end">Actual (lbs)</th>
<th class="text-end">Variance</th>
<th>Notes</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var u in Model.PowderUsageLogs)
{
var variance = u.VarianceLbs;
<tr>
<td class="text-nowrap">@u.RecordedAt.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd/yyyy")</td>
<td class="text-nowrap">
@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)
{
<td>
@if (u.InventoryItemId.HasValue)
{
<a asp-action="Ledger" asp-route-inventoryItemId="@u.InventoryItemId" class="text-decoration-none">
<span class="fw-semibold">@u.ItemName</span>
<br /><small class="text-muted">@u.SKU</small>
</a>
}
else
{
<span class="text-muted fst-italic">Custom/External</span>
}
</td>
}
<td>@(u.CoatColor ?? "—")</td>
<td class="text-end">@u.EstimatedLbs.ToString("N3")</td>
<td class="text-end fw-semibold">@u.ActualLbsUsed.ToString("N3")</td>
<td class="text-end @(variance > 0 ? "variance-over" : variance < 0 ? "variance-under" : "")">
@(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>
<tfoot class="table-light fw-semibold">
<tr>
<td colspan="@(Model.InventoryItemId.HasValue ? 4 : 5)">Totals</td>
<td class="text-end">@Model.PowderUsageLogs.Sum(u => u.EstimatedLbs).ToString("N3")</td>
<td class="text-end">@Model.PowderUsageLogs.Sum(u => u.ActualLbsUsed).ToString("N3")</td>
<td class="text-end @(Model.PowderUsageLogs.Sum(u => u.VarianceLbs) > 0 ? "variance-over" : "variance-under")">
@(Model.PowderUsageLogs.Sum(u => u.VarianceLbs) > 0 ? "+" : "")@Model.PowderUsageLogs.Sum(u => u.VarianceLbs).ToString("N3")
</td>
<td></td>
<td></td>
</tr>
</tfoot>
</table>
</div>
@if (Model.PowderUsageLogs.Count == 500)
{
<p class="text-muted small">Showing the 500 most recent usage records. Use filters to narrow results.</p>
}
}
</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');
document.querySelector('input[name="tab"]').value = tab;
}
</script>
}