87bbf158a4
- InventoryController: extract RecordInventoryUsageAsync helper; both LogUsage (scan page) and LogMaterial (jobs modal, moved from JobsController) call it — no more duplicate save/GL logic across two controllers - Log Material modal: replace radio buttons with prominent toggle buttons so the active mode (Amount Used vs Amount Remaining) is always visually obvious; add always-visible preview line showing exactly what will be logged before saving - Edit Usage modal: add quantity field (pre-populated from existing transaction) with delta adjustment to InventoryItem.QuantityOnHand on save; include completed/terminal jobs in the dropdown so entries can be corrected after a job is marked done - Scan page job picker: include jobs completed within the last 7 days (marked with '(completed)') so usage can be logged after a job is finished Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
401 lines
20 KiB
Plaintext
401 lines
20 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">
|
|
<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">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>@Html.Raw(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="euQuantity" class="form-label fw-semibold">Amount Used <small class="text-muted fw-normal" id="euQuantityUom"></small></label>
|
|
<input type="number" id="euQuantity" name="quantity" class="form-control" min="0.001" step="any" required />
|
|
<div class="form-text">Adjusts the inventory balance by the difference from the original entry.</div>
|
|
</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>
|
|
}
|