Merge dev into master: inventory bin filter, print bin, mobile login fixes, QR scan fix
This commit is contained in:
@@ -68,6 +68,7 @@ public class InventoryListDto
|
||||
public string? CategoryName { get; set; }
|
||||
public string Category { get; set; } = string.Empty; // Legacy field
|
||||
public string? ColorName { get; set; }
|
||||
public string? Location { get; set; }
|
||||
public decimal QuantityOnHand { get; set; }
|
||||
public string UnitOfMeasure { get; set; } = "lbs";
|
||||
public decimal ReorderPoint { get; set; }
|
||||
|
||||
@@ -1209,6 +1209,15 @@ Rules:
|
||||
sb.AppendLine("Page content:");
|
||||
sb.AppendLine(pageContent);
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(fetchUrl))
|
||||
{
|
||||
// Page content unavailable (fetch failed or blocked) — still surface the URL so Claude
|
||||
// can use its training knowledge of the manufacturer URL structure (e.g. Prismatic SKU
|
||||
// in the path) to infer product identity rather than returning all-null fields.
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Product URL (page content could not be fetched): {fetchUrl}");
|
||||
sb.AppendLine("Use your training knowledge of this manufacturer and the URL to fill in as many fields as possible.");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ public class InventoryController : Controller
|
||||
public async Task<IActionResult> Index(
|
||||
string? searchTerm,
|
||||
string? category,
|
||||
string? location,
|
||||
string? sortColumn,
|
||||
string sortDirection = "asc",
|
||||
bool lowStockOnly = false,
|
||||
@@ -87,50 +88,64 @@ public class InventoryController : Controller
|
||||
};
|
||||
gridRequest.Validate();
|
||||
|
||||
// Build search and category filter
|
||||
// Build filter — compose search, category, location, and low-stock predicates
|
||||
System.Linq.Expressions.Expression<Func<InventoryItem, bool>>? filter = null;
|
||||
|
||||
if (lowStockOnly && !string.IsNullOrWhiteSpace(searchTerm))
|
||||
{
|
||||
var search = searchTerm.ToLower();
|
||||
var hasSearch = !string.IsNullOrWhiteSpace(searchTerm);
|
||||
var hasCategory = !string.IsNullOrWhiteSpace(category);
|
||||
var hasLocation = !string.IsNullOrWhiteSpace(location);
|
||||
|
||||
var search = searchTerm?.ToLower() ?? "";
|
||||
var cat = category ?? "";
|
||||
var loc = location ?? "";
|
||||
|
||||
if (lowStockOnly && hasSearch && hasLocation)
|
||||
filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint
|
||||
&& (i.SKU.ToLower().Contains(search)
|
||||
|| i.Name.ToLower().Contains(search)
|
||||
&& (i.Location != null && i.Location.ToLower() == loc.ToLower())
|
||||
&& (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|
||||
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
||||
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)));
|
||||
}
|
||||
else if (lowStockOnly && hasSearch)
|
||||
filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint
|
||||
&& (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|
||||
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
||||
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)));
|
||||
else if (lowStockOnly && hasLocation)
|
||||
filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint
|
||||
&& (i.Location != null && i.Location.ToLower() == loc.ToLower());
|
||||
else if (lowStockOnly)
|
||||
{
|
||||
filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(searchTerm) && !string.IsNullOrWhiteSpace(category))
|
||||
{
|
||||
// Both search and category filter
|
||||
var search = searchTerm.ToLower();
|
||||
var cat = category;
|
||||
filter = i => (i.SKU.ToLower().Contains(search)
|
||||
|| i.Name.ToLower().Contains(search)
|
||||
|| (i.Description != null && i.Description.ToLower().Contains(search))
|
||||
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
||||
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)))
|
||||
else if (hasSearch && hasCategory && hasLocation)
|
||||
filter = i => (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|
||||
|| (i.Description != null && i.Description.ToLower().Contains(search))
|
||||
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
||||
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)))
|
||||
&& i.Category.ToLower() == cat.ToLower()
|
||||
&& (i.Location != null && i.Location.ToLower() == loc.ToLower());
|
||||
else if (hasSearch && hasCategory)
|
||||
filter = i => (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|
||||
|| (i.Description != null && i.Description.ToLower().Contains(search))
|
||||
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
||||
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)))
|
||||
&& i.Category.ToLower() == cat.ToLower();
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
{
|
||||
// Search only
|
||||
var search = searchTerm.ToLower();
|
||||
filter = i => i.SKU.ToLower().Contains(search)
|
||||
|| i.Name.ToLower().Contains(search)
|
||||
else if (hasSearch && hasLocation)
|
||||
filter = i => (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|
||||
|| (i.Description != null && i.Description.ToLower().Contains(search))
|
||||
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
||||
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)))
|
||||
&& (i.Location != null && i.Location.ToLower() == loc.ToLower());
|
||||
else if (hasSearch)
|
||||
filter = i => i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|
||||
|| (i.Description != null && i.Description.ToLower().Contains(search))
|
||||
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
||||
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search));
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(category))
|
||||
{
|
||||
// Category filter only
|
||||
var cat = category;
|
||||
else if (hasCategory && hasLocation)
|
||||
filter = i => i.Category.ToLower() == cat.ToLower()
|
||||
&& (i.Location != null && i.Location.ToLower() == loc.ToLower());
|
||||
else if (hasCategory)
|
||||
filter = i => i.Category.ToLower() == cat.ToLower();
|
||||
}
|
||||
else if (hasLocation)
|
||||
filter = i => i.Location != null && i.Location.ToLower() == loc.ToLower();
|
||||
|
||||
// Build orderBy function
|
||||
Func<IQueryable<InventoryItem>, IOrderedQueryable<InventoryItem>> orderBy = gridRequest.SortColumn switch
|
||||
@@ -159,19 +174,21 @@ public class InventoryController : Controller
|
||||
|
||||
var pagedResult = PagedResult<InventoryListDto>.From(gridRequest, itemDtos, totalCount);
|
||||
|
||||
// Load all items once to compute sidebar stats and category list in memory
|
||||
// Load all items once to compute sidebar stats and dropdown option lists in memory
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var allItems = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
|
||||
ViewBag.Categories = allItems.Select(i => i.Category).Where(c => c != null).Distinct().OrderBy(c => c).ToList();
|
||||
ViewBag.Locations = allItems.Select(i => i.Location).Where(l => !string.IsNullOrWhiteSpace(l)).Distinct().OrderBy(l => l).ToList();
|
||||
ViewBag.StatsLowStockCount = allItems.Count(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
|
||||
ViewBag.StatsActiveCount = allItems.Count(i => i.IsActive);
|
||||
ViewBag.StatsTotalValue = allItems.Sum(i => (decimal?)i.QuantityOnHand * i.UnitCost) ?? 0m;
|
||||
|
||||
// Set ViewBag for sorting and filters
|
||||
ViewBag.SearchTerm = searchTerm;
|
||||
ViewBag.Category = category;
|
||||
ViewBag.SearchTerm = searchTerm;
|
||||
ViewBag.Category = category;
|
||||
ViewBag.Location = location;
|
||||
ViewBag.LowStockOnly = lowStockOnly;
|
||||
ViewBag.SortColumn = gridRequest.SortColumn;
|
||||
ViewBag.SortColumn = gridRequest.SortColumn;
|
||||
ViewBag.SortDirection = gridRequest.SortDirection;
|
||||
|
||||
return View(pagedResult);
|
||||
@@ -184,6 +201,26 @@ public class InventoryController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a print-optimised list of all active inventory items in a given bin/location.
|
||||
/// Renders without the site chrome (no layout) so the browser print dialog produces a
|
||||
/// clean page. Items are sorted by name within the bin.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> PrintBin(string location)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(location))
|
||||
return RedirectToAction(nameof(Index));
|
||||
|
||||
var loc = location.Trim();
|
||||
var items = await _unitOfWork.InventoryItems.FindAsync(
|
||||
i => i.Location != null && i.Location.ToLower() == loc.ToLower());
|
||||
|
||||
var dtos = _mapper.Map<List<InventoryListDto>>(items.OrderBy(i => i.Name).ToList());
|
||||
ViewBag.Location = loc;
|
||||
ViewBag.PrintedAt = DateTime.Now;
|
||||
return View(dtos);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the inventory item detail page. The primary vendor name is looked up
|
||||
/// separately because the repository does not eager-load Vendor by default, avoiding
|
||||
@@ -897,10 +934,28 @@ public class InventoryController : Controller
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(qrUrl))
|
||||
{
|
||||
// QR path: fetch the product page; LookupByUrlAsync now maps all identity + spec fields
|
||||
aiResult = await _aiLookupService.LookupByUrlAsync(qrUrl, null);
|
||||
if (aiResult.Success && aiResult.SpecPageUrl == null)
|
||||
aiResult.SpecPageUrl = qrUrl;
|
||||
// QR path: try to extract product identity from the URL using manufacturer patterns.
|
||||
// Prismatic Powders URLs embed both the SKU and color slug in the path
|
||||
// (e.g. /shop/powder-coating-colors/PMB-6906/fire-red), so we can feed the full
|
||||
// LookupAsync pipeline (Serper + direct URL + Claude) with real context instead of
|
||||
// relying solely on a page fetch that may fail in production.
|
||||
var activePatterns = (await _unitOfWork.ManufacturerLookupPatterns.GetAllAsync(ignoreQueryFilters: true))
|
||||
.Where(p => p.IsActive).ToList();
|
||||
var (urlMfr, urlColor, urlPart) = TryParseManufacturerUrl(qrUrl, activePatterns);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(urlMfr))
|
||||
{
|
||||
aiResult = await _aiLookupService.LookupAsync(urlMfr, urlColor, null, urlPart);
|
||||
if (aiResult.Success && aiResult.SpecPageUrl == null)
|
||||
aiResult.SpecPageUrl = qrUrl;
|
||||
}
|
||||
else
|
||||
{
|
||||
// No pattern match — fall back to URL-based lookup
|
||||
aiResult = await _aiLookupService.LookupByUrlAsync(qrUrl, null);
|
||||
if (aiResult.Success && aiResult.SpecPageUrl == null)
|
||||
aiResult.SpecPageUrl = qrUrl;
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(imageBase64))
|
||||
{
|
||||
@@ -1272,6 +1327,72 @@ public class InventoryController : Controller
|
||||
return transferEfficiency ?? DefaultTransferEfficiency;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reverse-parses a scanned QR URL against known manufacturer URL templates to extract
|
||||
/// product identity (manufacturer name, color name, part number). For example, a Prismatic
|
||||
/// Powders URL like /shop/powder-coating-colors/PMB-6906/fire-red yields manufacturer
|
||||
/// "Prismatic Powders", partNumber "PMB-6906", and colorName "Fire Red". Returns nulls
|
||||
/// when no active pattern matches the URL domain.
|
||||
/// </summary>
|
||||
private static (string? manufacturer, string? colorName, string? partNumber) TryParseManufacturerUrl(
|
||||
string url, IEnumerable<Core.Entities.ManufacturerLookupPattern> patterns)
|
||||
{
|
||||
Uri? parsed;
|
||||
try { parsed = new Uri(url); } catch { return (null, null, null); }
|
||||
|
||||
var host = parsed.Host.Replace("www.", "", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var pattern in patterns.Where(p => !string.IsNullOrEmpty(p.Domain) && !string.IsNullOrEmpty(p.ProductUrlTemplate)))
|
||||
{
|
||||
if (!host.Equals(pattern.Domain, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
|
||||
var template = pattern.ProductUrlTemplate!;
|
||||
var placeholderKeys = new[] { "{partNumber}", "{slug}", "{colorName}", "{colorCode}" };
|
||||
|
||||
// Find the first placeholder to split off the static prefix
|
||||
var firstIdx = placeholderKeys
|
||||
.Select(ph => template.IndexOf(ph, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(i => i >= 0)
|
||||
.DefaultIfEmpty(-1)
|
||||
.Min();
|
||||
|
||||
if (firstIdx < 0) continue;
|
||||
|
||||
var staticPrefix = template[..firstIdx].TrimEnd('/');
|
||||
var fullUrl = url.TrimEnd('/');
|
||||
if (!fullUrl.StartsWith(staticPrefix, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
|
||||
var templateSegments = template[firstIdx..].Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
var urlSegments = fullUrl[staticPrefix.Length..].Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
var extracted = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
bool matched = true;
|
||||
for (int i = 0; i < templateSegments.Length && i < urlSegments.Length; i++)
|
||||
{
|
||||
var seg = templateSegments[i];
|
||||
if (seg.StartsWith("{") && seg.EndsWith("}"))
|
||||
extracted[seg[1..^1]] = urlSegments[i];
|
||||
else if (!seg.Equals(urlSegments[i], StringComparison.OrdinalIgnoreCase))
|
||||
{ matched = false; break; }
|
||||
}
|
||||
if (!matched) continue;
|
||||
|
||||
extracted.TryGetValue("partNumber", out var partNumber);
|
||||
var slug = extracted.TryGetValue("slug", out var s) ? s
|
||||
: extracted.TryGetValue("colorName", out var cn) ? cn
|
||||
: extracted.TryGetValue("colorCode", out var cc) ? cc : null;
|
||||
|
||||
// Convert URL slug to display name: "fire-red" → "Fire Red"
|
||||
string? colorName = slug == null ? null
|
||||
: string.Join(" ", slug.Split(new[] { '-', '_' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(w => w.Length > 0 ? char.ToUpperInvariant(w[0]) + w[1..].ToLowerInvariant() : w));
|
||||
|
||||
return (pattern.ManufacturerName, colorName, partNumber);
|
||||
}
|
||||
|
||||
return (null, null, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a string to title-case using the current culture's TextInfo. Applied to
|
||||
/// inventory item names on create and edit so the list view is consistently formatted
|
||||
|
||||
@@ -301,7 +301,18 @@ public static class HelpKnowledgeBase
|
||||
|
||||
**Time Entries:** Track labor time on a job from the Details page.
|
||||
|
||||
**Rework:** If a job needs to be redone, a rework record can be created from the Details page. Tracks rework type, reason, and resolution.
|
||||
**Rework / Redo:** Rework and redo mean the same thing in this system. If a finished part fails QA or a customer brings it back damaged, open the original job's Details page and use the **Rework Log** section to record it.
|
||||
|
||||
Each rework entry captures: the type (internal defect, customer damage, warranty), the reason (adhesion failure, color mismatch, runs/sags, insufficient coverage, etc.), a defect description, who discovered it, and pricing responsibility.
|
||||
|
||||
**Pricing responsibility options:**
|
||||
- **Shop Fault — no charge:** All rework job item prices are set to $0. Use this when the defect is the shop's fault.
|
||||
- **Customer responsible — reduced rate:** Item prices are copied from the original job. Edit them down after the rework job is created.
|
||||
- **Customer responsible — full price:** Item prices are copied from the original job as-is.
|
||||
|
||||
**Creating a rework job:** Toggle "Parts are back — create a Rework Job" in the log form. Select which items need to be redone and choose the pricing responsibility. The system creates a new job with a sub-number (e.g., JOB-2605-0001-R1), copies the selected items with their coats and prep services, and auto-records intake (parts are already on hand). The rework job description shows the defect type, reason, and pricing at the top of the job where it is easy to see.
|
||||
|
||||
**Rework job completion:** When the rework job reaches a terminal status (Completed, Delivered, etc.), the linked rework record on the original job is automatically marked as **Resolved**. If the rework job is Cancelled instead, the record is marked **Written Off**. No manual follow-up on the original job is needed.
|
||||
|
||||
**Job Templates:** [/JobTemplates](/JobTemplates) — Save a job's items as a template to reuse for common work types. When creating a new job, select a template to pre-fill items.
|
||||
|
||||
@@ -462,6 +473,13 @@ public static class HelpKnowledgeBase
|
||||
- Every scan log is recorded as a JobUsage or Adjustment InventoryTransaction and immediately reduces QuantityOnHand; visible in Inventory Activity ledger
|
||||
- First-time scan on a new device requires login; browser caches the session after that
|
||||
|
||||
**Location / Bin filtering and printing:**
|
||||
- Every inventory item has an optional **Location** field (e.g. "Shelf A", "Bin 3") set on the Create/Edit form.
|
||||
- Once any item has a location, a **Location dropdown** appears in the Inventory list filter bar (next to Category). Selecting a location filters the list to only items stored there. Can be combined with keyword search and category filter simultaneously.
|
||||
- Each location badge in the table is a clickable link that instantly filters to that bin.
|
||||
- With a location filter active, a **Print Bin** button appears in the filter banner. Clicking it opens a printer-ready page (new tab) at /Inventory/PrintBin?location=... listing all items in that bin with line number, name, color, and SKU. No site chrome — prints cleanly on a standard sheet.
|
||||
- The Location dropdown only appears when at least one item has a location value set. If a user doesn't see it, they need to fill in the Location field on at least one inventory item.
|
||||
|
||||
**Catalog Lookup & Label Scanner (when adding/editing inventory items):**
|
||||
- When creating or editing an inventory item, click the **Lookup** button next to the SKU/Part Number field to search a built-in platform catalog of thousands of Prismatic Powders and other manufacturer SKUs. Select a match to auto-fill name, manufacturer, color code, finish, coverage rate, SDS/TDS links, and cure specs.
|
||||
- The catalog only shows products not already in the company's inventory (prevents duplicates). When editing, the item's own catalog entry is always shown.
|
||||
|
||||
@@ -180,6 +180,18 @@ builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
|
||||
.AddDefaultUI()
|
||||
.AddClaimsPrincipalFactory<ApplicationUserClaimsPrincipalFactory>();
|
||||
|
||||
// Configure the auth cookie to survive mobile browser suspensions (iOS Safari clears session
|
||||
// cookies when it suspends a tab). Max-Age on the cookie itself makes it persistent regardless
|
||||
// of whether the user checked "Remember me". SlidingExpiration renews the window on each request.
|
||||
builder.Services.ConfigureApplicationCookie(options =>
|
||||
{
|
||||
options.ExpireTimeSpan = TimeSpan.FromDays(30);
|
||||
options.SlidingExpiration = true;
|
||||
options.Cookie.MaxAge = TimeSpan.FromDays(30);
|
||||
options.Cookie.IsEssential = true;
|
||||
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||
});
|
||||
|
||||
// Register HttpContextAccessor for multi-tenancy
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
|
||||
@@ -530,6 +542,49 @@ builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
|
||||
// Return a proper HTML body on 429 so mobile browsers (especially iOS Safari) don't try to
|
||||
// download the empty response as a file. Without this, Safari shows "Login / data Zero KB".
|
||||
options.OnRejected = async (context, ct) =>
|
||||
{
|
||||
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||
|
||||
var isAjax = context.HttpContext.Request.Headers.XRequestedWith == "XMLHttpRequest"
|
||||
|| (context.HttpContext.Request.Headers.Accept.ToString().Contains("application/json"));
|
||||
|
||||
if (isAjax)
|
||||
{
|
||||
context.HttpContext.Response.ContentType = "application/json; charset=utf-8";
|
||||
await context.HttpContext.Response.WriteAsync(
|
||||
"""{"success":false,"error":"Too many requests. Please wait a moment and try again."}""", ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
context.HttpContext.Response.ContentType = "text/html; charset=utf-8";
|
||||
await context.HttpContext.Response.WriteAsync("""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Too Many Requests — Powder Coating Logix</title>
|
||||
<style>
|
||||
body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f5f5f5}
|
||||
.card{background:#fff;border-radius:8px;padding:2rem;max-width:420px;text-align:center;box-shadow:0 2px 8px rgba(0,0,0,.1)}
|
||||
h2{margin-top:0;color:#333}p{color:#666}a{color:#0d6efd}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2>Too Many Requests</h2>
|
||||
<p>You've made too many login attempts in a short period. Please wait a minute and try again.</p>
|
||||
<a href="/Identity/Account/Login">Back to Login</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
""", ct);
|
||||
}
|
||||
};
|
||||
|
||||
// login / password-reset — 10 per minute per IP
|
||||
options.AddPolicy(AppConstants.RateLimitPolicies.Auth, ctx =>
|
||||
RateLimitPartition.GetSlidingWindowLimiter(
|
||||
|
||||
@@ -73,6 +73,49 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="bin-filter" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-geo-alt text-primary me-2"></i>Filtering by Location & Printing a Bin List
|
||||
</h2>
|
||||
<p>
|
||||
Every inventory item has an optional <strong>Location</strong> field (for example "Shelf A",
|
||||
"Bin 3", or "Back Wall") that you can set when creating or editing an item. Once locations
|
||||
are filled in, the Inventory list gives you two shortcuts for physical counts and
|
||||
stocktaking:
|
||||
</p>
|
||||
|
||||
<h3 class="h6 fw-semibold mt-3 mb-2">Filtering by location</h3>
|
||||
<p>
|
||||
When at least one item has a location set, a <strong>Location</strong> dropdown appears in
|
||||
the filter bar at the top of the Inventory list, next to the Category filter. Select a
|
||||
location from the dropdown to instantly narrow the list to only the items stored there.
|
||||
You can combine the location filter with a keyword search or category filter at the same time.
|
||||
</p>
|
||||
<p>
|
||||
Each location badge shown in the Location column of the table is also a direct link —
|
||||
clicking it immediately filters to that bin without using the dropdown.
|
||||
</p>
|
||||
|
||||
<h3 class="h6 fw-semibold mt-4 mb-2">Printing a bin list</h3>
|
||||
<p>
|
||||
With a location filter active, a <strong>Print Bin</strong> button appears next to the
|
||||
Clear Filters button. Click it to open a printer-ready page (in a new tab) listing every
|
||||
item in that bin with its item number, name, color, and SKU. Click <strong>Print</strong>
|
||||
on that page or use your browser’s print shortcut to send it to a printer or save
|
||||
as PDF. The page has no site chrome — just the table — so it prints cleanly on a
|
||||
standard sheet.
|
||||
</p>
|
||||
|
||||
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
|
||||
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
The Location dropdown only appears once at least one inventory item has a location set.
|
||||
If you don’t see it, open any item, fill in the <strong>Location</strong> field on the
|
||||
edit form, and save — the dropdown will appear on your next visit to the Inventory list.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="catalog-lookup" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-search text-primary me-2"></i>Catalog Lookup & Label Scanner
|
||||
@@ -533,6 +576,7 @@
|
||||
<nav class="nav flex-column">
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#overview">Overview</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#adding-items">Adding Inventory Items</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#bin-filter">Location Filtering & Bin Print</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#catalog-lookup">Catalog Lookup & Label Scanner</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#stock-levels">Stock Levels and Reorder Points</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#stock-adjustment">Stock Adjustment</a>
|
||||
|
||||
@@ -123,10 +123,11 @@
|
||||
|
||||
@{
|
||||
var lowStockOnly = (bool)(ViewBag.LowStockOnly ?? false);
|
||||
var activeLocation = ViewBag.Location as string;
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(ViewBag.SearchTerm) || !string.IsNullOrEmpty(ViewBag.Category) || lowStockOnly)
|
||||
@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">
|
||||
<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)
|
||||
@@ -144,11 +145,24 @@
|
||||
{
|
||||
<span> in category "<strong>@ViewBag.Category</strong>"</span>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(activeLocation))
|
||||
{
|
||||
<span> in bin "<strong>@activeLocation</strong>"</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<a href="@Url.Action("Index")" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-x me-1"></i>Clear Filters
|
||||
</a>
|
||||
<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>
|
||||
}
|
||||
|
||||
@@ -161,14 +175,24 @@
|
||||
<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: 250px; min-width: 150px;" onchange="this.form.submit()">
|
||||
<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>
|
||||
<div class="input-group" style="max-width: 480px; min-width: 300px;">
|
||||
@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>
|
||||
@@ -210,6 +234,7 @@
|
||||
<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>
|
||||
@@ -250,6 +275,21 @@
|
||||
<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))
|
||||
{
|
||||
@@ -352,6 +392,15 @@
|
||||
<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">
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
@model IEnumerable<PowderCoating.Application.DTOs.Inventory.InventoryListDto>
|
||||
@{
|
||||
Layout = null;
|
||||
var location = ViewBag.Location as string ?? "";
|
||||
var printedAt = (DateTime)(ViewBag.PrintedAt ?? DateTime.Now);
|
||||
var items = Model.ToList();
|
||||
}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Bin @location — Inventory</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: Arial, Helvetica, sans-serif; font-size: 11pt; color: #111; background: #fff; padding: 20px; }
|
||||
.header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 2px solid #333; padding-bottom: 10px; margin-bottom: 14px; }
|
||||
.header h1 { font-size: 18pt; }
|
||||
.header .meta { font-size: 9pt; color: #555; text-align: right; line-height: 1.6; }
|
||||
.summary { font-size: 9.5pt; color: #444; margin-bottom: 14px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
thead th { background: #f0f0f0; border-top: 1px solid #bbb; border-bottom: 1px solid #bbb; padding: 6px 8px; text-align: left; font-size: 9pt; font-weight: 700; text-transform: uppercase; letter-spacing: .03em; }
|
||||
tbody td { padding: 5px 8px; border-bottom: 1px solid #e0e0e0; vertical-align: middle; font-size: 10pt; }
|
||||
tbody tr:last-child td { border-bottom: 2px solid #bbb; }
|
||||
.qty { text-align: right; font-weight: 600; }
|
||||
.qty.low { color: #c00; }
|
||||
.qty.out { color: #888; }
|
||||
.reorder { text-align: right; color: #555; }
|
||||
.cost { text-align: right; }
|
||||
.status-low { font-size: 8pt; color: #c00; font-weight: 600; }
|
||||
.status-out { font-size: 8pt; color: #888; font-weight: 600; }
|
||||
.footer { margin-top: 18px; font-size: 8.5pt; color: #888; border-top: 1px solid #ddd; padding-top: 8px; display: flex; justify-content: space-between; }
|
||||
@@media print {
|
||||
body { padding: 10px; }
|
||||
.no-print { display: none !important; }
|
||||
a { text-decoration: none; color: inherit; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="no-print" style="margin-bottom:16px;">
|
||||
<button onclick="window.print()" style="padding:6px 16px;font-size:11pt;cursor:pointer;margin-right:8px;">
|
||||
🖶 Print
|
||||
</button>
|
||||
<a href="javascript:history.back()" style="font-size:10pt;color:#0d6efd;">← Back</a>
|
||||
</div>
|
||||
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>Bin: @location</h1>
|
||||
<div style="font-size:10pt;color:#555;margin-top:4px;">Inventory count sheet</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
Powder Coating Logix<br />
|
||||
Printed: @printedAt.ToString("MMM d, yyyy h:mm tt")<br />
|
||||
@items.Count item@(items.Count == 1 ? "" : "s")
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!items.Any())
|
||||
{
|
||||
<p style="color:#888;margin-top:20px;">No active inventory items found in this location.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:5%">#</th>
|
||||
<th style="width:45%">Item Name</th>
|
||||
<th style="width:25%">Color</th>
|
||||
<th style="width:25%">SKU</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@{ var row = 0; }
|
||||
@foreach (var item in items)
|
||||
{
|
||||
row++;
|
||||
<tr>
|
||||
<td style="color:#aaa;font-size:9pt;">@row</td>
|
||||
<td><strong>@item.Name</strong></td>
|
||||
<td>@(item.ColorName ?? "—")</td>
|
||||
<td style="font-family:monospace;font-size:9.5pt;">@item.SKU</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
<div class="footer">
|
||||
<span>Bin: @location</span>
|
||||
<span>Powder Coating Logix — @printedAt.ToString("yyyy")</span>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user