Compare commits

...

15 Commits

Author SHA1 Message Date
spouliot 10f668fd73 Merge dev into master for prod deploy 2026-05-24 10:46:23 -04:00
spouliot 19b7a9a473 Fix equipment creation blocked by maintenance interval validation
RecommendedMaintenanceIntervalDays was a non-nullable int with [Range(1,3650)],
so submitting the form without filling it in bound to 0 and failed validation.
Made nullable on the entity, both DTOs, and the one controller callsite that
calls .AddDays() (now uses .Value). Migration applied.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 10:38:05 -04:00
spouliot e443457139 Update help docs and AI assistant for QR modal and vendor supply categories
- HelpKnowledgeBase.cs: QR label now opens a preview modal (not new tab); mention list-page QR button; add vendor supply categories knowledge
- Help/Inventory.cshtml: Update QR printing steps for modal workflow; document list-page QR button
- Help/Vendors.cshtml: Add Supply Categories section with filtering behaviour and fallback; add nav link

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 10:48:00 -04:00
spouliot edf56c1164 Fix iframe blocking: SAMEORIGIN + frame-ancestors 'self'
X-Frame-Options: DENY blocked all iframe embeds including our own QR label
modal. Changed to SAMEORIGIN and added frame-ancestors 'self' to CSP so
same-origin iframes (Label page) load correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 10:02:23 -04:00
spouliot b9cd693421 Fix QR label modal: allow self in frame-src CSP, fix Bootstrap API call
frame-src was missing 'self' so the Label iframe was blocked by CSP.
bootstrap.Modal.getOrCreate does not exist; correct method is getOrCreateInstance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 09:59:14 -04:00
spouliot d77b3778ac Add vendor supply categories with inventory auto-filter
Vendors can now be tagged with one or more inventory categories (Powder,
Chemical, etc.) via checkboxes on the Create/Edit form. The inventory
Create/Edit vendor dropdown automatically filters to matching vendors when
a category is selected; falls back to all vendors if none are tagged.
Includes migration AddVendorCategories (VendorInventoryCategories join table).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 09:52:34 -04:00
spouliot a7bf97a2df Add Print QR Label action to inventory list (desktop + mobile)
Opens the same iframe modal used on the Details page; the iframe src is
set dynamically so a single shared modal handles every row.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 09:35:13 -04:00
spouliot b7ab85ff92 Merge dev into master: QR scan URL fixes and http scheme failsafe 2026-05-22 17:41:01 -04:00
spouliot ce7b00b68c Merge dev into master: inventory bin filter, print bin, mobile login fixes, QR scan fix 2026-05-22 15:22:38 -04:00
spouliot c5c1244177 Merge dev into master
- Inline item editing on Job/Quote/Invoice Details pages
- Live pricing summary and Job Costing card updates on save
- PatchItem legacy fallback for jobs without PricingBreakdownJson
- GetCostingBreakdown revenue from FinalPrice (not invoice total)
- Help docs: Inline Price Editing sections added to all three detail pages
- AI knowledge base updated with inline editing and costing revenue behavior
- AGENTS.md tracked; .gitignore updated for Claude Code settings and build logs
- Resolve conflict in Payment/Index.cshtml (em dash entity style)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 08:35:29 -04:00
spouliot 25140554ad Merge hotfixes: Stripe receipt_email, surcharge fix, void deposit/credit, cache headers
- Remove receipt_email from Stripe PaymentIntent (any email accepted at checkout)
- Fix surcharge payment: input/validation based on total-with-fee, not base amount
- Add InvariantCulture to payment JS literals
- Fix voided invoice leaving deposits locked (re-releases for next invoice)
- Convert non-deposit payments to CRED- credits on void (preserves money trail)
- Cache-Control: no-store on authenticated pages (prevents browser cache corruption)
- Fix Edit Payment onclick encoding for apostrophes in reference/notes

Inline item editing (7fa385a) held in dev pending further testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:19:10 -04:00
spouliot 46cadea367 Add Cache-Control: no-store for authenticated pages; fix payment onclick encoding
Prevents browsers from caching authenticated pages, which resolves stale/corrupt
cache bugs (e.g. Firefox refusing to navigate to a specific invoice). Also fixes
the Edit Payment button onclick to use Json.Serialize for Reference/Notes so
apostrophes and other special characters don't break the JavaScript string literal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:18:04 -04:00
spouliot cfe937c0c3 Convert non-deposit payments to customer credits on invoice void
When voiding an invoice that has non-deposit payments (e.g. CC charges),
those payments are now converted to CRED- Deposit records so the money
trail is preserved and the credit auto-applies to the replacement invoice.
Deposits that were applied to the voided invoice are also re-released so
they can auto-apply again. Void confirmation dialog and success message
both reflect the credit amount when applicable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:18:03 -04:00
spouliot 3ad6b0d08f Fix voided invoice leaving deposits locked as applied
When an invoice was voided, deposits auto-applied at invoice creation
kept their AppliedToInvoiceId pointing at the voided invoice. The
replacement invoice lookup (AppliedToInvoiceId == null) skipped them,
so the deposit was never re-applied and the customer was charged in full.

Void now clears AppliedToInvoiceId/AppliedDate on all deposits tied to
the invoice so they're available for the next invoice, and credits the
CustomerDeposits 2300 liability account to restore the balance that was
debited when the deposits were originally applied.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:18:01 -04:00
spouliot fdac0240d1 Fix Stripe receipt_email + online payment surcharge and hardening
Remove receipt_email from PaymentIntent creation so customers can use
any email at Stripe checkout without a stored-email mismatch blocking
payment. Remove now-dead CustomerEmail from PaymentPageViewModel.

Fix surcharge payment input: amount field now represents the total the
customer pays (including fee); JS back-calculates base before sending
to server. Add InvariantCulture to numeric Razor→JS literals to prevent
comma-decimal cultures from truncating surcharge values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:17:57 -04:00
23 changed files with 21833 additions and 19 deletions
@@ -20,7 +20,7 @@ public class EquipmentDto
public string StatusDisplay { get; set; } = string.Empty;
public string? Location { get; set; }
public int RecommendedMaintenanceIntervalDays { get; set; }
public int? RecommendedMaintenanceIntervalDays { get; set; }
public DateTime? LastMaintenanceDate { get; set; }
public DateTime? NextScheduledMaintenance { get; set; }
public int? DaysUntilMaintenance { get; set; }
@@ -101,7 +101,7 @@ public class CreateEquipmentDto
[Range(1, 3650, ErrorMessage = "Maintenance interval must be between 1 and 3650 days")]
[Display(Name = "Recommended Maintenance Interval (Days)")]
public int RecommendedMaintenanceIntervalDays { get; set; }
public int? RecommendedMaintenanceIntervalDays { get; set; }
[Display(Name = "Last Maintenance Date")]
public DateTime? LastMaintenanceDate { get; set; }
@@ -125,6 +125,8 @@ public class CreateVendorDto
[Display(Name = "Default Expense Account")]
public int? DefaultExpenseAccountId { get; set; }
public List<int> CategoryIds { get; set; } = new();
}
// ============================================================================
@@ -209,4 +211,6 @@ public class UpdateVendorDto
[Display(Name = "Default Expense Account")]
public int? DefaultExpenseAccountId { get; set; }
public List<int> CategoryIds { get; set; } = new();
}
+1 -1
View File
@@ -19,7 +19,7 @@ public class Equipment : BaseEntity
public string? Location { get; set; }
// Maintenance Information
public int RecommendedMaintenanceIntervalDays { get; set; }
public int? RecommendedMaintenanceIntervalDays { get; set; }
public DateTime? LastMaintenanceDate { get; set; }
public DateTime? NextScheduledMaintenance { get; set; }
@@ -12,4 +12,5 @@ public class InventoryCategoryLookup : BaseEntity
// Relationships
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
public virtual ICollection<Vendor> Vendors { get; set; } = new List<Vendor>();
}
@@ -45,6 +45,7 @@ public class Vendor : BaseEntity
public virtual ICollection<BillPayment> BillPayments { get; set; } = new List<BillPayment>();
public virtual ICollection<Expense> Expenses { get; set; } = new List<Expense>();
public virtual Account? DefaultExpenseAccount { get; set; }
public virtual ICollection<InventoryCategoryLookup> Categories { get; set; } = new List<InventoryCategoryLookup>();
}
public class InventoryTransaction : BaseEntity
@@ -809,6 +809,15 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
.HasForeignKey(s => s.DefaultExpenseAccountId)
.OnDelete(DeleteBehavior.SetNull);
// Vendor ↔ InventoryCategoryLookup (many-to-many supply categories)
modelBuilder.Entity<Vendor>()
.HasMany(v => v.Categories)
.WithMany(c => c.Vendors)
.UsingEntity<Dictionary<string, object>>(
"VendorInventoryCategories",
j => j.HasOne<InventoryCategoryLookup>().WithMany().HasForeignKey("InventoryCategoryLookupId"),
j => j.HasOne<Vendor>().WithMany().HasForeignKey("VendorId"));
// Bill → APAccount (no cascade to avoid cycles)
modelBuilder.Entity<Bill>()
.HasOne(b => b.APAccount)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,93 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddVendorCategories : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "VendorInventoryCategories",
columns: table => new
{
InventoryCategoryLookupId = table.Column<int>(type: "int", nullable: false),
VendorId = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_VendorInventoryCategories", x => new { x.InventoryCategoryLookupId, x.VendorId });
table.ForeignKey(
name: "FK_VendorInventoryCategories_InventoryCategoryLookups_InventoryCategoryLookupId",
column: x => x.InventoryCategoryLookupId,
principalTable: "InventoryCategoryLookups",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_VendorInventoryCategories_Vendors_VendorId",
column: x => x.VendorId,
principalTable: "Vendors",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4300));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4313));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4315));
migrationBuilder.CreateIndex(
name: "IX_VendorInventoryCategories_VendorId",
table: "VendorInventoryCategories",
column: "VendorId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "VendorInventoryCategories");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8533));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8542));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8543));
}
}
}
@@ -0,0 +1,79 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class MakeMaintenanceIntervalNullable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "RecommendedMaintenanceIntervalDays",
table: "Equipment",
type: "int",
nullable: true,
oldClrType: typeof(int),
oldType: "int");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8197));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8203));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8204));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "RecommendedMaintenanceIntervalDays",
table: "Equipment",
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, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4300));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4313));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4315));
}
}
}
@@ -3045,7 +3045,7 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<decimal>("PurchasePrice")
.HasColumnType("decimal(18,2)");
b.Property<int>("RecommendedMaintenanceIntervalDays")
b.Property<int?>("RecommendedMaintenanceIntervalDays")
.HasColumnType("int");
b.Property<string>("SerialNumber")
@@ -6711,7 +6711,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8533),
CreatedAt = new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8197),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -6722,7 +6722,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8542),
CreatedAt = new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8203),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -6733,7 +6733,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8543),
CreatedAt = new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8204),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -8634,6 +8634,21 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("YearEndCloses");
});
modelBuilder.Entity("VendorInventoryCategories", b =>
{
b.Property<int>("InventoryCategoryLookupId")
.HasColumnType("int");
b.Property<int>("VendorId")
.HasColumnType("int");
b.HasKey("InventoryCategoryLookupId", "VendorId");
b.HasIndex("VendorId");
b.ToTable("VendorInventoryCategories");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
@@ -10372,6 +10387,21 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("JournalEntry");
});
modelBuilder.Entity("VendorInventoryCategories", b =>
{
b.HasOne("PowderCoating.Core.Entities.InventoryCategoryLookup", null)
.WithMany()
.HasForeignKey("InventoryCategoryLookupId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("PowderCoating.Core.Entities.Vendor", null)
.WithMany()
.HasForeignKey("VendorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("PowderCoating.Core.Entities.Account", b =>
{
b.Navigation("BillLineItems");
@@ -1495,8 +1495,20 @@ public class InventoryController : Controller
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
ViewBag.AiInventoryAssistEnabled = await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId);
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId);
ViewBag.Vendors = new SelectList(vendors.Where(s => s.IsActive).OrderBy(s => s.CompanyName), "Id", "CompanyName");
var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.IsActive, false, v => v.Categories))
.OrderBy(v => v.CompanyName).ToList();
ViewBag.Vendors = new SelectList(vendors, "Id", "CompanyName");
// Build {categoryId: [vendorId, ...]} so the inventory form can filter vendors by category
var categoryVendorMap = new Dictionary<string, List<int>>();
foreach (var v in vendors)
foreach (var cat in v.Categories)
{
var key = cat.Id.ToString();
if (!categoryVendorMap.ContainsKey(key)) categoryVendorMap[key] = new List<int>();
categoryVendorMap[key].Add(v.Id);
}
ViewBag.CategoryVendorMapJson = System.Text.Json.JsonSerializer.Serialize(categoryVendorMap);
// Load categories from lookup table
var allCategories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
@@ -580,7 +580,7 @@ public class MaintenanceController : Controller
// Calculate next scheduled maintenance
if (equipment.RecommendedMaintenanceIntervalDays > 0)
{
equipment.NextScheduledMaintenance = DateTime.UtcNow.AddDays(equipment.RecommendedMaintenanceIntervalDays);
equipment.NextScheduledMaintenance = DateTime.UtcNow.AddDays(equipment.RecommendedMaintenanceIntervalDays.Value);
}
equipment.UpdatedAt = DateTime.UtcNow;
@@ -181,6 +181,7 @@ public class VendorsController : Controller
public async Task<IActionResult> Create(bool inline = false)
{
await PopulateExpenseAccountsAsync();
await PopulateVendorCategoriesAsync();
if (inline)
return PartialView(new CreateVendorDto());
return View(new CreateVendorDto());
@@ -207,6 +208,7 @@ public class VendorsController : Controller
return Json(new { success = false, errors });
}
await PopulateExpenseAccountsAsync();
await PopulateVendorCategoriesAsync(dto.CategoryIds);
return View(dto);
}
@@ -216,6 +218,12 @@ public class VendorsController : Controller
var vendor = _mapper.Map<Vendor>(dto);
vendor.CompanyId = currentUser!.CompanyId;
if (dto.CategoryIds.Any())
{
var cats = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => dto.CategoryIds.Contains(c.Id));
vendor.Categories = cats.ToList();
}
await _unitOfWork.Vendors.AddAsync(vendor);
await _unitOfWork.CompleteAsync();
@@ -247,14 +255,16 @@ public class VendorsController : Controller
try
{
var vendor = await _unitOfWork.Vendors.GetByIdAsync(id.Value);
var vendor = await _unitOfWork.Vendors.GetByIdAsync(id.Value, false, v => v.Categories);
if (vendor == null)
{
return NotFound();
}
var dto = _mapper.Map<UpdateVendorDto>(vendor);
dto.CategoryIds = vendor.Categories.Select(c => c.Id).ToList();
await PopulateExpenseAccountsAsync();
await PopulateVendorCategoriesAsync(dto.CategoryIds);
return View(dto);
}
catch (Exception ex)
@@ -282,18 +292,27 @@ public class VendorsController : Controller
if (!ModelState.IsValid)
{
await PopulateExpenseAccountsAsync();
await PopulateVendorCategoriesAsync(dto.CategoryIds);
return View(dto);
}
try
{
var vendor = await _unitOfWork.Vendors.GetByIdAsync(id);
var vendor = await _unitOfWork.Vendors.GetByIdAsync(id, false, v => v.Categories);
if (vendor == null)
{
return NotFound();
}
_mapper.Map(dto, vendor);
vendor.Categories.Clear();
if (dto.CategoryIds.Any())
{
var cats = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => dto.CategoryIds.Contains(c.Id));
foreach (var cat in cats) vendor.Categories.Add(cat);
}
await _unitOfWork.Vendors.UpdateAsync(vendor);
await _unitOfWork.CompleteAsync();
@@ -413,6 +432,20 @@ public class VendorsController : Controller
/// purchases) in addition to regular operating expenses. A "— None —" placeholder is prepended so
/// the field is optional — not every vendor needs a default account pre-set.
/// </summary>
/// <summary>
/// Populates ViewBag.VendorCategories with active inventory categories for the checkbox list,
/// and ViewBag.SelectedCategoryIds with the IDs already assigned to the vendor being edited.
/// </summary>
private async Task PopulateVendorCategoriesAsync(IEnumerable<int>? selectedIds = null)
{
var companyId = (await _userManager.GetUserAsync(User))!.CompanyId;
var cats = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId && c.IsActive))
.OrderBy(c => c.DisplayOrder)
.ToList();
ViewBag.VendorCategories = cats;
ViewBag.SelectedCategoryIds = (selectedIds ?? Enumerable.Empty<int>()).ToHashSet();
}
private async Task PopulateExpenseAccountsAsync()
{
var accounts = (await _unitOfWork.Accounts.FindAsync(
@@ -466,7 +466,8 @@ public static class HelpKnowledgeBase
Filter by item, date range, and transaction type. Summary pills show total lbs received, used, and net adjustments. Access from the sidebar ("Inventory Activity") or via "View Activity History" on any item's Details page (pre-filtered to that item).
**QR Code Labels & Mobile Usage Logging:**
- Print a QR label from any item's Details page click "Print QR Label" opens a standalone print page with the item name, SKU, colour, and QR code. Print and stick on the bag/bin.
- Print a QR label from any item's Details page OR from the QR icon in the Actions column on the Inventory list page click "Print QR Label" a preview modal opens with the label (item name, SKU, colour, finish, and QR code). Click "Print Label" inside the modal to send to your printer without opening a new tab.
- You can also print from the list page without opening the item: click the QR icon (first button in the action column) next to any row.
- Scan the QR code with a phone camera opens the mobile-friendly Log Usage page at /Inventory/Scan/[item-id]
- On the scan page: select a job (My Jobs shows jobs assigned to the logged-in user; Other Jobs shows all open jobs; No Job logs without a reference), enter quantity used (live balance preview shown), choose reason (Job Usage / Waste / Correction / Transfer Out), add optional notes, tap Save
- After saving: success screen with "Log Another Item for This Job" (returns to scan with same job pre-selected) or "Back to Inventory" / "View Item Details"
+4 -3
View File
@@ -671,8 +671,8 @@ System.Globalization.CultureInfo.DefaultThreadCurrentUICulture = cultureInfo;
// SECURITY: Add security headers middleware
app.Use(async (context, next) =>
{
// Prevent clickjacking
context.Response.Headers.Append("X-Frame-Options", "DENY");
// Prevent clickjacking — SAMEORIGIN so our own iframe embeds (QR labels, etc.) still work
context.Response.Headers.Append("X-Frame-Options", "SAMEORIGIN");
// Prevent MIME type sniffing
context.Response.Headers.Append("X-Content-Type-Options", "nosniff");
@@ -699,7 +699,8 @@ app.Use(async (context, next) =>
"font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; " +
"img-src 'self' data: https:; " +
$"connect-src {cspConnectSrc}; " +
"frame-src https://js.stripe.com https://hooks.stripe.com");
"frame-src 'self' https://js.stripe.com https://hooks.stripe.com; " +
"frame-ancestors 'self'");
// Referrer Policy - control referrer information
context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
@@ -347,10 +347,14 @@
</p>
<h3 class="h6 fw-semibold mt-4 mb-2">Printing a label</h3>
<p>You can print from two places &mdash; the item's Details page, or directly from the list without opening the item.</p>
<ol class="mb-3">
<li class="mb-1">Open the inventory item's Details page.</li>
<li class="mb-1">Click <strong>Print QR Label</strong> in the Actions panel &mdash; the label opens in a new tab.</li>
<li class="mb-1">Click <strong>Print Label</strong> and send it to your printer. The label is sized for a standard 3.5&Prime; label and includes the item name, SKU, colour, finish, and manufacturer.</li>
<li class="mb-1">
<strong>From the list:</strong> click the <i class="bi bi-qr-code"></i> QR icon (first button in the Actions column) next to any row.
<br /><strong>From Details:</strong> click <strong>Print QR Label</strong> in the Actions panel.
</li>
<li class="mb-1">A preview modal opens showing the label &mdash; item name, SKU, colour, finish, and QR code.</li>
<li class="mb-1">Click <strong>Print Label</strong> inside the modal to send it to your printer. No new tab is opened; printing happens directly in the modal. The label is sized for a standard 3.5&Prime; label.</li>
</ol>
<h3 class="h6 fw-semibold mt-4 mb-2">Scanning and logging usage</h3>
@@ -149,6 +149,43 @@
</div>
</section>
<section id="supply-categories" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-tags text-primary me-2"></i>Supply Categories
</h2>
<p>
Each vendor can be tagged with one or more <strong>Supply Categories</strong> &mdash; for example,
<em>Powder</em>, <em>Chemical</em>, or <em>Consumables</em>. These tags tell the system what types of
inventory items this vendor supplies.
</p>
<p>
When you add or edit an inventory item and choose a <strong>Category</strong>, the vendor dropdown
automatically filters to show only vendors tagged for that category. This prevents accidentally
selecting an unrelated supplier.
</p>
<ul class="mb-3">
<li>A vendor can belong to <strong>multiple categories</strong> &mdash; check as many boxes as apply.</li>
<li>If <strong>no vendor</strong> in your list is tagged for the selected category, the dropdown falls back to
showing all active vendors so you are never blocked.</li>
<li>A <em>&ldquo;Showing vendors for this category&rdquo;</em> note appears below the dropdown when the filter is
active. Click <strong>Show all</strong> in that note to temporarily override the filter.</li>
</ul>
<p>To tag a vendor with supply categories:</p>
<ol class="mb-3">
<li class="mb-1">Open the vendor's Edit page (click the vendor name in the list, then <strong>Edit</strong>).</li>
<li class="mb-1">In the <strong>Supply Categories</strong> section, check the boxes that apply.</li>
<li class="mb-1">Click <strong>Save Vendor</strong>.</li>
</ol>
<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>
Supply categories are the same categories used on your inventory items. If you add a new category
under <strong>Settings &rsaquo; Inventory Categories</strong>, it will appear automatically in the vendor
checkboxes.
</div>
</div>
</section>
<section id="deactivating-a-vendor" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-truck text-primary me-2" style="text-decoration:line-through"></i>Deactivating a Vendor
@@ -186,6 +223,7 @@
<a class="nav-link py-1 px-3 small text-body" href="#default-expense-account">Default Expense Account</a>
<a class="nav-link py-1 px-3 small text-body" href="#payment-terms">Payment Terms</a>
<a class="nav-link py-1 px-3 small text-body" href="#preferred-vendor">Preferred Vendor</a>
<a class="nav-link py-1 px-3 small text-body" href="#supply-categories">Supply Categories</a>
<a class="nav-link py-1 px-3 small text-body" href="#vendor-details">Vendor Details Page</a>
<a class="nav-link py-1 px-3 small text-body" href="#deactivating-a-vendor">Deactivating a Vendor</a>
</nav>
@@ -345,6 +345,10 @@
<option value="">Select vendor</option>
<option value="__new__">+ Add New Vendor&hellip;</option>
</select>
<div id="vendor-filter-note" class="form-text d-none">
<i class="bi bi-funnel me-1 text-info"></i><span class="text-info">Showing vendors for this category.</span>
<a href="#" id="vendor-filter-clear" class="ms-1">Show all</a>
</div>
<span asp-validation-for="PrimaryVendorId" class="text-danger"></span>
</div>
</div>
@@ -438,4 +442,38 @@
{
<script src="~/js/inventory-label-scan.js"></script>
}
<script>
(function () {
const categoryVendorMap = @Html.Raw(ViewBag.CategoryVendorMapJson ?? "{}");
const vendorSelect = document.getElementById('field-vendor');
const allVendorOptions = Array.from(vendorSelect.options).map(o => ({ v: o.value, t: o.text }));
function filterVendors(catId, forceAll) {
const vendorIds = (!forceAll && catId) ? (categoryVendorMap[catId] || []) : [];
const isFiltered = vendorIds.length > 0;
const currentVal = vendorSelect.value;
vendorSelect.innerHTML = '';
allVendorOptions.forEach(function (opt) {
if (!isFiltered || !opt.v || opt.v === '__new__' || vendorIds.includes(Number(opt.v)))
vendorSelect.add(new Option(opt.t, opt.v));
});
if (Array.from(vendorSelect.options).some(o => o.value === currentVal))
vendorSelect.value = currentVal;
document.getElementById('vendor-filter-note').classList.toggle('d-none', !isFiltered);
}
document.getElementById('field-category').addEventListener('change', function () {
filterVendors(this.value, false);
});
document.getElementById('vendor-filter-clear')?.addEventListener('click', function (e) {
e.preventDefault();
filterVendors(document.getElementById('field-category').value, true);
});
filterVendors(document.getElementById('field-category').value, false);
})();
</script>
}
@@ -341,6 +341,10 @@
<option value="">Select vendor</option>
<option value="__new__">+ Add New Vendor&hellip;</option>
</select>
<div id="vendor-filter-note" class="form-text d-none">
<i class="bi bi-funnel me-1 text-info"></i><span class="text-info">Showing vendors for this category.</span>
<a href="#" id="vendor-filter-clear" class="ms-1">Show all</a>
</div>
<span asp-validation-for="PrimaryVendorId" class="text-danger"></span>
</div>
<div class="col-12">
@@ -457,4 +461,39 @@
{
<script src="~/js/inventory-label-scan.js"></script>
}
<script>
(function () {
const categoryVendorMap = @Html.Raw(ViewBag.CategoryVendorMapJson ?? "{}");
const vendorSelect = document.getElementById('field-vendor');
const allVendorOptions = Array.from(vendorSelect.options).map(o => ({ v: o.value, t: o.text }));
function filterVendors(catId, forceAll) {
const vendorIds = (!forceAll && catId) ? (categoryVendorMap[catId] || []) : [];
const isFiltered = vendorIds.length > 0;
const currentVal = vendorSelect.value;
vendorSelect.innerHTML = '';
allVendorOptions.forEach(function (opt) {
if (!isFiltered || !opt.v || opt.v === '__new__' || vendorIds.includes(Number(opt.v)))
vendorSelect.add(new Option(opt.t, opt.v));
});
if (Array.from(vendorSelect.options).some(o => o.value === currentVal))
vendorSelect.value = currentVal;
document.getElementById('vendor-filter-note').classList.toggle('d-none', !isFiltered);
}
document.getElementById('field-category').addEventListener('change', function () {
filterVendors(this.value, false);
});
document.getElementById('vendor-filter-clear')?.addEventListener('click', function (e) {
e.preventDefault();
filterVendors(document.getElementById('field-category').value, true);
});
// Apply on load — Edit already has a category selected
filterVendors(document.getElementById('field-category').value, false);
})();
</script>
}
@@ -340,6 +340,11 @@
</td>
<td class="text-end pe-4">
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-secondary"
title="Print QR Label"
onclick="openQrLabelModal(@item.Id, event)">
<i class="bi bi-qr-code"></i>
</button>
<a asp-action="Details" asp-route-id="@item.Id" class="btn btn-outline-primary" title="View Details">
<i class="bi bi-eye"></i>
</a>
@@ -454,6 +459,10 @@
</div>
<div class="mobile-card-footer">
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="openQrLabelModal(@item.Id, event)">
<i class="bi bi-qr-code me-1"></i>QR Label
</button>
<a href="@Url.Action("Details", new { id = item.Id })"
class="btn btn-sm btn-outline-primary"
onclick="event.stopPropagation();">
@@ -477,8 +486,42 @@
}
</div>
@* QR Label Modal (shared across all items — src set dynamically by JS) *@
<div class="modal fade" id="qrLabelModal" tabindex="-1" aria-labelledby="qrLabelModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title" id="qrLabelModalLabel">
<i class="bi bi-qr-code me-2"></i>QR Label
</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0 d-flex justify-content-center" style="background:#f0f0f0;min-height:360px;">
<iframe id="qrLabelFrame"
src="about:blank"
style="width:100%;height:400px;border:none;"
title="QR Label Preview"></iframe>
</div>
<div class="modal-footer py-2">
<button type="button" class="btn btn-primary btn-sm"
onclick="document.getElementById('qrLabelFrame').contentWindow.print()">
<i class="bi bi-printer me-2"></i>Print Label
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
function openQrLabelModal(itemId, e) {
e.stopPropagation();
const frame = document.getElementById('qrLabelFrame');
frame.src = '@Url.Action("Label", "Inventory")/' + itemId + '?embed=true';
bootstrap.Modal.getOrCreateInstance(document.getElementById('qrLabelModal')).show();
}
// Make table rows clickable
document.querySelectorAll('.inventory-row').forEach(row => {
row.addEventListener('click', function(e) {
@@ -195,6 +195,27 @@
</div>
</div>
<!-- Supply Categories -->
@if (ViewBag.VendorCategories is IEnumerable<PowderCoating.Core.Entities.InventoryCategoryLookup> cats && cats.Any())
{
<div class="mb-4">
<h5 class="border-bottom pb-2 mb-3">
<i class="bi bi-tags me-2 text-primary"></i>Supply Categories
</h5>
<div class="d-flex flex-wrap gap-3">
@foreach (var cat in cats)
{
<div class="form-check">
<input class="form-check-input" type="checkbox"
name="CategoryIds" value="@cat.Id" id="cat_@cat.Id" />
<label class="form-check-label" for="cat_@cat.Id">@cat.DisplayName</label>
</div>
}
</div>
<div class="form-text">Tag this vendor with the types of supplies they provide. Used to filter the vendor list when adding inventory items.</div>
</div>
}
<!-- Notes Section -->
<div class="mb-4">
<h5 class="border-bottom pb-2 mb-3">
@@ -198,6 +198,29 @@
</div>
</div>
<!-- Supply Categories -->
@if (ViewBag.VendorCategories is IEnumerable<PowderCoating.Core.Entities.InventoryCategoryLookup> cats && cats.Any())
{
var selectedCatIds = (HashSet<int>)ViewBag.SelectedCategoryIds;
<div class="mb-4">
<h5 class="border-bottom pb-2 mb-3">
<i class="bi bi-tags me-2 text-primary"></i>Supply Categories
</h5>
<div class="d-flex flex-wrap gap-3">
@foreach (var cat in cats)
{
<div class="form-check">
<input class="form-check-input" type="checkbox"
name="CategoryIds" value="@cat.Id" id="cat_@cat.Id"
@(selectedCatIds.Contains(cat.Id) ? "checked" : "") />
<label class="form-check-label" for="cat_@cat.Id">@cat.DisplayName</label>
</div>
}
</div>
<div class="form-text">Tag this vendor with the types of supplies they provide. Used to filter the vendor list when adding inventory items.</div>
</div>
}
<!-- Notes Section -->
<div class="mb-4">
<h5 class="border-bottom pb-2 mb-3">