dfb1d34af3
- Inventory: location filter dropdown + Print Bin page (line #, name, color, SKU) - Fix: Prismatic Powders QR scan now extracts manufacturer/SKU/color from URL path and uses full LookupAsync pipeline instead of relying on page fetch alone - Fix: iOS Safari 'Login / data Zero KB' download -- add OnRejected HTML response to rate limiter - Fix: mobile session logout -- ConfigureApplicationCookie with 30-day MaxAge persistent cookie - Help: new 'Location Filtering & Bin Print' section in Inventory help article - Help: HelpKnowledgeBase updated with bin filter and print bin details Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
505 lines
27 KiB
Plaintext
505 lines
27 KiB
Plaintext
@model PagedResult<PowderCoating.Application.DTOs.Inventory.InventoryListDto>
|
|
|
|
@{
|
|
ViewData["Title"] = "Inventory";
|
|
ViewData["PageIcon"] = "bi-box-seam";
|
|
ViewData["PageHelpTitle"] = "Inventory";
|
|
ViewData["PageHelpContent"] = "Track powder coatings, consumables, and other shop materials. Items show as Low Stock when quantity falls at or below the Reorder Point — the Low Stock count at the top is your reorder alert. Click any row to view full details or edit. Use the search box and category filter to narrow the list. Low Stock filter shows only items needing attention.";
|
|
var lowStockCount = (int)(ViewBag.StatsLowStockCount ?? 0);
|
|
var activeCount = (int)(ViewBag.StatsActiveCount ?? 0);
|
|
var totalValue = (decimal)(ViewBag.StatsTotalValue ?? 0m);
|
|
}
|
|
|
|
<div class="d-flex justify-content-end align-items-center mb-4">
|
|
<div class="d-flex gap-2">
|
|
<a asp-action="Ledger" class="btn btn-outline-secondary">
|
|
<i class="bi bi-journal-text me-2"></i>Inventory Activity
|
|
</a>
|
|
<a asp-controller="PowderInsights" asp-action="Index" class="btn btn-outline-secondary" title="Powder usage analytics">
|
|
<i class="bi bi-graph-up me-2"></i>Powder Insights
|
|
</a>
|
|
<a asp-action="SamplePanels" class="btn btn-outline-primary">
|
|
<i class="bi bi-palette me-2"></i>Manage Sample Panels
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stats Cards - Desktop -->
|
|
<div class="stats-cards-desktop">
|
|
<div class="row g-3 mb-4">
|
|
<div class="col-md-3">
|
|
<div class="card border-0 shadow-sm">
|
|
<div class="card-body">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<p class="text-muted mb-1" style="font-size: 0.875rem;">Total Items</p>
|
|
<h3 class="mb-0 fw-bold">@Model.TotalCount</h3>
|
|
</div>
|
|
<div class="rounded-circle p-3" style="background: #dbeafe;">
|
|
<i class="bi bi-box-seam text-primary" style="font-size: 1.5rem;"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-md-3">
|
|
<div class="card border-0 shadow-sm">
|
|
<div class="card-body">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<p class="text-muted mb-1" style="font-size: 0.875rem;">Low Stock Items</p>
|
|
<h3 class="mb-0 fw-bold @(lowStockCount > 0 ? "text-danger" : "")">@lowStockCount</h3>
|
|
</div>
|
|
<div class="rounded-circle p-3" style="background: #fee2e2;">
|
|
<i class="bi bi-exclamation-triangle text-danger" style="font-size: 1.5rem;"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-md-3">
|
|
<div class="card border-0 shadow-sm">
|
|
<div class="card-body">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<p class="text-muted mb-1" style="font-size: 0.875rem;">Active Items</p>
|
|
<h3 class="mb-0 fw-bold">@activeCount</h3>
|
|
</div>
|
|
<div class="rounded-circle p-3" style="background: #d1fae5;">
|
|
<i class="bi bi-check-circle text-success" style="font-size: 1.5rem;"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-md-3">
|
|
<div class="card border-0 shadow-sm">
|
|
<div class="card-body">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<p class="text-muted mb-1" style="font-size: 0.875rem;">Total Value</p>
|
|
<h3 class="mb-0 fw-bold">@totalValue.ToString("C")</h3>
|
|
</div>
|
|
<div class="rounded-circle p-3" style="background: #fef3c7;">
|
|
<i class="bi bi-currency-dollar text-warning" style="font-size: 1.5rem;"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Compact Stats - Mobile -->
|
|
<div class="mobile-stats-compact">
|
|
<div class="card">
|
|
<div class="stats-grid">
|
|
<div class="stat-item">
|
|
<div class="stat-icon"><i class="bi bi-box-seam text-primary"></i></div>
|
|
<div class="stat-value">@Model.TotalCount</div>
|
|
<div class="stat-label">Total</div>
|
|
</div>
|
|
<div class="stat-item">
|
|
<div class="stat-icon"><i class="bi bi-exclamation-triangle text-danger"></i></div>
|
|
<div class="stat-value @(lowStockCount > 0 ? "text-danger" : "")">@lowStockCount</div>
|
|
<div class="stat-label">Low Stock</div>
|
|
</div>
|
|
<div class="stat-item">
|
|
<div class="stat-icon"><i class="bi bi-check-circle text-success"></i></div>
|
|
<div class="stat-value">@activeCount</div>
|
|
<div class="stat-label">Active</div>
|
|
</div>
|
|
<div class="stat-item">
|
|
<div class="stat-icon"><i class="bi bi-currency-dollar text-warning"></i></div>
|
|
<div class="stat-value">@totalValue.ToString("C0")</div>
|
|
<div class="stat-label">Value</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
@{
|
|
var lowStockOnly = (bool)(ViewBag.LowStockOnly ?? false);
|
|
var activeLocation = ViewBag.Location as string;
|
|
}
|
|
@if (!string.IsNullOrEmpty(ViewBag.SearchTerm) || !string.IsNullOrEmpty(ViewBag.Category) || !string.IsNullOrEmpty(activeLocation) || lowStockOnly)
|
|
{
|
|
<div class="alert @(lowStockOnly ? "alert-warning" : "alert-info") alert-permanent d-flex justify-content-between align-items-center flex-wrap gap-2">
|
|
<div>
|
|
<i class="bi bi-funnel-fill me-2"></i>
|
|
@if (lowStockOnly)
|
|
{
|
|
<span>Showing <strong>@Model.TotalCount</strong> low stock item@(Model.TotalCount == 1 ? "" : "s") — at or below reorder point</span>
|
|
}
|
|
else
|
|
{
|
|
<span>Showing <strong>@Model.TotalCount</strong> item(s)</span>
|
|
@if (!string.IsNullOrEmpty(ViewBag.SearchTerm))
|
|
{
|
|
<span> matching "<strong>@ViewBag.SearchTerm</strong>"</span>
|
|
}
|
|
@if (!string.IsNullOrEmpty(ViewBag.Category))
|
|
{
|
|
<span> in category "<strong>@ViewBag.Category</strong>"</span>
|
|
}
|
|
@if (!string.IsNullOrEmpty(activeLocation))
|
|
{
|
|
<span> in bin "<strong>@activeLocation</strong>"</span>
|
|
}
|
|
}
|
|
</div>
|
|
<div class="d-flex gap-2 flex-wrap">
|
|
@if (!string.IsNullOrEmpty(activeLocation))
|
|
{
|
|
<a href="@Url.Action("PrintBin", new { location = activeLocation })" target="_blank"
|
|
class="btn btn-sm btn-outline-primary" title="Print bin list">
|
|
<i class="bi bi-printer me-1"></i>Print Bin
|
|
</a>
|
|
}
|
|
<a href="@Url.Action("Index")" class="btn btn-sm btn-outline-secondary">
|
|
<i class="bi bi-x me-1"></i>Clear Filters
|
|
</a>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<!-- Inventory Table Card -->
|
|
<div class="card border-0 shadow-sm">
|
|
<div class="card-header bg-white border-0 py-3">
|
|
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-start align-items-lg-center gap-3">
|
|
<div class="d-flex flex-column flex-sm-row gap-2 w-100 w-lg-auto">
|
|
<form asp-action="Index" method="get" class="d-flex flex-column flex-sm-row gap-2 flex-grow-1 flex-lg-grow-0">
|
|
<input type="hidden" name="sortColumn" value="@ViewBag.SortColumn" />
|
|
<input type="hidden" name="sortDirection" value="@ViewBag.SortDirection" />
|
|
<input type="hidden" name="pageSize" value="@Model.PageSize" />
|
|
<select name="category" class="form-select" style="max-width: 180px; min-width: 130px;" onchange="this.form.submit()">
|
|
<option value="">All Categories</option>
|
|
@foreach (var cat in ViewBag.Categories)
|
|
{
|
|
<option value="@cat" selected="@(cat == ViewBag.Category)">@cat</option>
|
|
}
|
|
</select>
|
|
@if (((IEnumerable<string?>)ViewBag.Locations).Any())
|
|
{
|
|
<select name="location" class="form-select" style="max-width: 180px; min-width: 130px;" onchange="this.form.submit()">
|
|
<option value="">All Locations</option>
|
|
@foreach (var loc in ViewBag.Locations)
|
|
{
|
|
<option value="@loc" selected="@(loc == activeLocation)">@loc</option>
|
|
}
|
|
</select>
|
|
}
|
|
<div class="input-group" style="max-width: 380px; min-width: 260px;">
|
|
<span class="input-group-text bg-white border-end-0">
|
|
<i class="bi bi-search text-muted"></i>
|
|
</span>
|
|
<input type="text" name="searchTerm" class="form-control border-start-0"
|
|
placeholder="Search inventory..."
|
|
value="@ViewBag.SearchTerm">
|
|
<button type="submit" class="btn btn-primary">
|
|
<i class="bi bi-search"></i>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
<a asp-action="Create" class="btn btn-primary text-nowrap">
|
|
<i class="bi bi-plus-circle me-2"></i>
|
|
<span class="d-none d-sm-inline">Add Item</span>
|
|
<span class="d-inline d-sm-none">Add</span>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
@if (!Model.Items.Any())
|
|
{
|
|
var isInventoryFiltered = !string.IsNullOrEmpty(ViewBag.SearchTerm as string) || !string.IsNullOrEmpty(ViewBag.Category as string) || lowStockOnly;
|
|
<div class="text-center py-5">
|
|
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
|
|
<h5 class="mt-3 text-muted">No inventory items found</h5>
|
|
<p class="text-muted mb-4">@(isInventoryFiltered ? "No items match your current filters." : "Get started by adding your first inventory item.")</p>
|
|
<a asp-action="Create" class="btn btn-primary">
|
|
<i class="bi bi-plus-circle me-2"></i>@(isInventoryFiltered ? "Add Item" : "Add Your First Item")
|
|
</a>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="table-responsive">
|
|
<table class="table table-hover mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th sortable="Name" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection" class="ps-4">Item Name</th>
|
|
<th sortable="Category" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Category</th>
|
|
<th sortable="ColorName" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Color</th>
|
|
<th>Location</th>
|
|
<th>Vendor</th>
|
|
<th sortable="QuantityOnHand" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Quantity</th>
|
|
<th>Reorder Point</th>
|
|
<th sortable="UnitCost" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Unit Cost</th>
|
|
<th>Stock Value</th>
|
|
<th sortable="IsActive" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Status</th>
|
|
<th class="text-end pe-4">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="inventoryTable">
|
|
@foreach (var item in Model.Items)
|
|
{
|
|
<tr class="inventory-row" data-item-id="@item.Id" style="cursor: pointer;">
|
|
<td class="ps-4">
|
|
<div class="d-flex align-items-center gap-2">
|
|
<div class="rounded-circle d-flex align-items-center justify-content-center"
|
|
style="width: 40px; height: 40px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; font-weight: 600;">
|
|
<i class="bi bi-box"></i>
|
|
</div>
|
|
<div>
|
|
<div class="fw-semibold">@item.Name</div>
|
|
<small class="text-muted">@item.SKU</small>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<span class="badge bg-secondary bg-opacity-10 text-secondary">
|
|
@item.Category
|
|
</span>
|
|
</td>
|
|
<td>
|
|
@if (!string.IsNullOrEmpty(item.ColorName))
|
|
{
|
|
<span>@item.ColorName</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="text-muted">—</span>
|
|
}
|
|
</td>
|
|
<td>
|
|
@if (!string.IsNullOrEmpty(item.Location))
|
|
{
|
|
<a href="@Url.Action("Index", new { location = item.Location })"
|
|
class="badge bg-info bg-opacity-10 text-info text-decoration-none"
|
|
onclick="event.stopPropagation();"
|
|
title="Filter by this location">
|
|
<i class="bi bi-geo-alt me-1"></i>@item.Location
|
|
</a>
|
|
}
|
|
else
|
|
{
|
|
<span class="text-muted">—</span>
|
|
}
|
|
</td>
|
|
<td>
|
|
@if (!string.IsNullOrEmpty(item.PrimaryVendorName))
|
|
{
|
|
<span class="text-muted">@item.PrimaryVendorName</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="text-muted">—</span>
|
|
}
|
|
</td>
|
|
<td>
|
|
<span class="@(item.IsOutOfStock ? "text-dark fw-semibold" : item.IsLowStock ? "text-danger fw-semibold" : "")">
|
|
@item.QuantityOnHand.ToString("N2") @item.UnitOfMeasure
|
|
@if (item.IsOutOfStock)
|
|
{
|
|
<i class="bi bi-x-circle ms-1"></i>
|
|
}
|
|
else if (item.IsLowStock)
|
|
{
|
|
<i class="bi bi-exclamation-triangle ms-1"></i>
|
|
}
|
|
</span>
|
|
</td>
|
|
<td>@item.ReorderPoint.ToString("N2") @item.UnitOfMeasure</td>
|
|
<td>@item.UnitCost.ToString("C")</td>
|
|
<td>
|
|
<span class="fw-semibold">@((item.QuantityOnHand * item.UnitCost).ToString("C"))</span>
|
|
</td>
|
|
<td>
|
|
@if (item.IsIncoming)
|
|
{
|
|
<span class="badge bg-warning bg-opacity-25 text-warning-emphasis">
|
|
<i class="bi bi-truck me-1"></i>Incoming
|
|
</span>
|
|
}
|
|
else if (item.IsActive)
|
|
{
|
|
<span class="badge bg-success bg-opacity-10 text-success">
|
|
<i class="bi bi-check-circle me-1"></i>Active
|
|
</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="badge bg-danger bg-opacity-10 text-danger">
|
|
<i class="bi bi-x-circle me-1"></i>Inactive
|
|
</span>
|
|
}
|
|
</td>
|
|
<td class="text-end pe-4">
|
|
<div class="btn-group btn-group-sm">
|
|
<a asp-action="Details" asp-route-id="@item.Id" class="btn btn-outline-primary" title="View Details">
|
|
<i class="bi bi-eye"></i>
|
|
</a>
|
|
<a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-outline-warning" title="Edit">
|
|
<i class="bi bi-pencil"></i>
|
|
</a>
|
|
<a asp-action="Delete" asp-route-id="@item.Id" class="btn btn-outline-danger" title="Delete">
|
|
<i class="bi bi-trash"></i>
|
|
</a>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Mobile Card View -->
|
|
<div class="mobile-card-view">
|
|
<div class="mobile-card-list">
|
|
@foreach (var item in Model.Items)
|
|
{
|
|
<div class="mobile-data-card"
|
|
data-id="@item.Id"
|
|
onclick="window.location.href='@Url.Action("Details", new { id = item.Id })'">
|
|
|
|
<div class="mobile-card-header">
|
|
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
|
|
<i class="bi bi-box"></i>
|
|
</div>
|
|
<div class="mobile-card-title">
|
|
<h6>@item.Name</h6>
|
|
<small>@item.SKU</small>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mobile-card-body">
|
|
<div class="mobile-card-row">
|
|
<span class="mobile-card-label">Category</span>
|
|
<span class="mobile-card-value">
|
|
<span class="badge bg-secondary bg-opacity-10 text-secondary">
|
|
@item.Category
|
|
</span>
|
|
</span>
|
|
</div>
|
|
@if (!string.IsNullOrEmpty(item.ColorName))
|
|
{
|
|
<div class="mobile-card-row">
|
|
<span class="mobile-card-label">Color</span>
|
|
<span class="mobile-card-value">@item.ColorName</span>
|
|
</div>
|
|
}
|
|
@if (!string.IsNullOrEmpty(item.Location))
|
|
{
|
|
<div class="mobile-card-row">
|
|
<span class="mobile-card-label">Location</span>
|
|
<span class="mobile-card-value">
|
|
<i class="bi bi-geo-alt me-1 text-info"></i>@item.Location
|
|
</span>
|
|
</div>
|
|
}
|
|
@if (!string.IsNullOrEmpty(item.PrimaryVendorName))
|
|
{
|
|
<div class="mobile-card-row">
|
|
<span class="mobile-card-label">Vendor</span>
|
|
<span class="mobile-card-value">@item.PrimaryVendorName</span>
|
|
</div>
|
|
}
|
|
<div class="mobile-card-row">
|
|
<span class="mobile-card-label">Quantity</span>
|
|
<span class="mobile-card-value @(item.IsOutOfStock ? "text-dark fw-semibold" : item.IsLowStock ? "text-danger fw-semibold" : "")">
|
|
@item.QuantityOnHand.ToString("N2") @item.UnitOfMeasure
|
|
@if (item.IsOutOfStock)
|
|
{
|
|
<i class="bi bi-x-circle ms-1"></i>
|
|
}
|
|
else if (item.IsLowStock)
|
|
{
|
|
<i class="bi bi-exclamation-triangle ms-1"></i>
|
|
}
|
|
</span>
|
|
</div>
|
|
<div class="mobile-card-row">
|
|
<span class="mobile-card-label">Reorder Point</span>
|
|
<span class="mobile-card-value">@item.ReorderPoint.ToString("N2") @item.UnitOfMeasure</span>
|
|
</div>
|
|
<div class="mobile-card-row">
|
|
<span class="mobile-card-label">Unit Cost</span>
|
|
<span class="mobile-card-value">@item.UnitCost.ToString("C")</span>
|
|
</div>
|
|
<div class="mobile-card-row">
|
|
<span class="mobile-card-label">Stock Value</span>
|
|
<span class="mobile-card-value fw-semibold text-primary">@((item.QuantityOnHand * item.UnitCost).ToString("C"))</span>
|
|
</div>
|
|
<div class="mobile-card-row">
|
|
<span class="mobile-card-label">Status</span>
|
|
<span class="mobile-card-value">
|
|
@if (item.IsActive)
|
|
{
|
|
<span class="badge bg-success bg-opacity-10 text-success">
|
|
<i class="bi bi-check-circle me-1"></i>Active
|
|
</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="badge bg-danger bg-opacity-10 text-danger">
|
|
<i class="bi bi-x-circle me-1"></i>Inactive
|
|
</span>
|
|
}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mobile-card-footer">
|
|
<a href="@Url.Action("Details", new { id = item.Id })"
|
|
class="btn btn-sm btn-outline-primary"
|
|
onclick="event.stopPropagation();">
|
|
<i class="bi bi-eye me-1"></i>View
|
|
</a>
|
|
<a href="@Url.Action("Edit", new { id = item.Id })"
|
|
class="btn btn-sm btn-outline-secondary"
|
|
onclick="event.stopPropagation();">
|
|
<i class="bi bi-pencil me-1"></i>Edit
|
|
</a>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
@if (Model.TotalCount > 0)
|
|
{
|
|
@await Html.PartialAsync("_Pagination", Model)
|
|
}
|
|
</div>
|
|
|
|
@section Scripts {
|
|
<script>
|
|
// Make table rows clickable
|
|
document.querySelectorAll('.inventory-row').forEach(row => {
|
|
row.addEventListener('click', function(e) {
|
|
// Don't navigate if clicking on action buttons or links
|
|
if (e.target.closest('.btn-group') || e.target.closest('a') || e.target.closest('button')) {
|
|
return;
|
|
}
|
|
|
|
const itemId = this.getAttribute('data-item-id');
|
|
window.location.href = '@Url.Action("Details", "Inventory")/' + itemId;
|
|
});
|
|
|
|
// Add hover effect
|
|
row.addEventListener('mouseenter', function() {
|
|
this.style.backgroundColor = '#f8f9fa';
|
|
});
|
|
|
|
row.addEventListener('mouseleave', function() {
|
|
this.style.backgroundColor = '';
|
|
});
|
|
});
|
|
</script>
|
|
}
|