Compare commits

...

5 Commits

Author SHA1 Message Date
spouliot e4a256a6c4 Fix subscription expiry logic and HTML entities in page titles
Subscription expiry (SubscriptionExpiryBackgroundService):
- Trials with no grace period now go directly Active -> Expired instead
  of briefly entering GracePeriod for a day, which was causing repeated
  'Grace Period Started' admin notification emails
- Remove redundant isTrial variable (query already filters to non-Stripe
  companies, so all processed companies are trials by definition)
- Save per-company inside the loop so a single SaveChangesAsync failure
  no longer discards all other companies' status changes and notification
  log entries (which was the other cause of repeated emails)

HTML entities in page titles (33 views):
- Replace – / — with plain ' - ' in ViewData["Title"] C#
  strings; Razor HTML-encodes these when rendering @ViewData["Title"],
  causing browsers to display the literal text '–' instead of a dash

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 09:58:37 -04:00
spouliot 04d16109ae Simplify location display on inventory QR label
Plain text 'Location: <value>' in larger bold font instead of
pill badge with map pin icon.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 18:12:10 -04:00
spouliot f0f3717681 Fix three bugs: vendor duplicate check, page size dropdown, label location
- Vendor Create: reject duplicate company names (case-insensitive) before
  saving; works for both the standalone form and the inline quick-add modal
- _Pagination: define changePageSize() JS function (was called but never
  existed, breaking page size dropdown on every paginated list)
- Inventory Label: show bin/location on printed QR code labels

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 18:12:07 -04:00
spouliot e23b006139 Add color family filter to inventory index
Adds an 'All Colors' dropdown to the inventory filter bar populated from
the ColorFamilies values already stored on inventory items. Selecting a
family (e.g. 'Red') returns only items tagged with that family.

Also refactors the 16-branch if/else filter builder into a single
composable predicate, making future filter additions trivial.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 18:12:05 -04:00
spouliot 0f35946973 Fix dark mode: main settings nav tab buttons showing white UA background
The #settingsTabs <button> elements had no explicit background-color,
letting browser UA button styling (white) bleed through in dark mode.
Added transparent overrides so the dark body background shows instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 18:12:02 -04:00
38 changed files with 235 additions and 154 deletions
@@ -67,9 +67,9 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
} }
/// <summary> /// <summary>
/// Opens a DI scope, queries non-Stripe-managed companies with active or grace-period subscriptions, /// Opens a DI scope, queries non-Stripe-managed (trial) companies with active or grace-period
/// and calls <see cref="ProcessCompanyAsync"/> for each. A single <c>SaveChangesAsync</c> at the /// subscriptions, and calls <see cref="ProcessCompanyAsync"/> for each. Each company is saved
/// end batches all status mutations into one round-trip. Errors are caught to keep the loop alive. /// individually so a single failure does not prevent other companies from being updated.
/// </summary> /// </summary>
private async Task RunAsync(CancellationToken ct) private async Task RunAsync(CancellationToken ct)
{ {
@@ -103,15 +103,27 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
_logger.LogDebug("Found {Count} companies to evaluate.", companies.Count); _logger.LogDebug("Found {Count} companies to evaluate.", companies.Count);
// All companies reaching this point have no StripeSubscriptionId — they are trials.
// Paid subscribers are managed by Stripe and filtered out above.
var effectiveGraceDays = gracePeriodAppliesToTrials ? gracePeriodDays : 0;
foreach (var company in companies) foreach (var company in companies)
{ {
if (ct.IsCancellationRequested) break; if (ct.IsCancellationRequested) break;
var isTrial = string.IsNullOrEmpty(company.StripeSubscriptionId); try
var effectiveGraceDays = isTrial && !gracePeriodAppliesToTrials ? 0 : gracePeriodDays; {
await ProcessCompanyAsync(db, emailService, adminNotification, company, today, effectiveGraceDays, ct); await ProcessCompanyAsync(db, emailService, adminNotification, company, today, effectiveGraceDays, ct);
await db.SaveChangesAsync(ct);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to process subscription expiry for company {Id} ({Name}). Status change was not persisted.",
company.Id, company.CompanyName);
// Clear EF tracked changes so bad state does not bleed into the next company.
db.ChangeTracker.Clear();
}
} }
await db.SaveChangesAsync(ct);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -121,10 +133,15 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
/// <summary> /// <summary>
/// Evaluates a single company and performs any required status transitions or reminder sends. /// Evaluates a single company and performs any required status transitions or reminder sends.
/// Transition logic: Active past end date → GracePeriod; GracePeriod past grace deadline → Expired + deactivated. /// Transition logic:
/// <list type="bullet">
/// <item>Active past end date, grace days = 0 → Expired + deactivated immediately (trials).</item>
/// <item>Active past end date, grace days &gt; 0 → GracePeriod + grace-period email.</item>
/// <item>GracePeriod past grace deadline → Expired + deactivated.</item>
/// </list>
/// Reminder emails at <see cref="ReminderDays"/> offsets are sent only while the company is still Active. /// Reminder emails at <see cref="ReminderDays"/> offsets are sent only while the company is still Active.
/// Platform admin is notified asynchronously (fire-and-forget) for both grace period start and full expiry /// Platform admin is notified asynchronously (fire-and-forget) so that operator action can be taken
/// so that operator action can be taken without delaying the main processing loop. /// without delaying the main processing loop.
/// </summary> /// </summary>
private async Task ProcessCompanyAsync( private async Task ProcessCompanyAsync(
ApplicationDbContext db, ApplicationDbContext db,
@@ -153,35 +170,55 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
await WriteAuditLogAsync(db, company, await WriteAuditLogAsync(db, company,
$"Auto-expired: grace period ended {gracePeriodDays} days after subscription end {endDate:d}."); $"Auto-expired: grace period ended {gracePeriodDays} days after subscription end {endDate:d}.");
// Notify platform admin
_ = adminNotification.NotifyCompanyExpiredAsync( _ = adminNotification.NotifyCompanyExpiredAsync(
company.Id, company.CompanyName, company.Id, company.CompanyName,
company.PrimaryContactEmail ?? string.Empty, expiredDate); company.PrimaryContactEmail ?? string.Empty, expiredDate);
} }
else if (company.SubscriptionStatus == SubscriptionStatus.Active && today > endDate) else if (company.SubscriptionStatus == SubscriptionStatus.Active && today > endDate)
{ {
_logger.LogInformation( if (gracePeriodDays == 0)
"Company {Id} ({Name}) subscription ended. Entering grace period.", {
company.Id, company.CompanyName); // No grace period configured — expire immediately without going through GracePeriod.
// Trials always land here since gracePeriodAppliesToTrials defaults to false.
_logger.LogInformation(
"Company {Id} ({Name}) subscription ended with no grace period. Marking Expired and deactivating.",
company.Id, company.CompanyName);
company.SubscriptionStatus = SubscriptionStatus.GracePeriod; company.SubscriptionStatus = SubscriptionStatus.Expired;
company.UpdatedAt = DateTime.UtcNow; company.IsActive = false;
company.UpdatedBy = "System"; company.UpdatedAt = DateTime.UtcNow;
company.UpdatedBy = "System";
await WriteAuditLogAsync(db, company, await WriteAuditLogAsync(db, company,
$"Auto-moved to GracePeriod: subscription ended {endDate:d}. Grace period expires {expiredDate:d}."); $"Auto-expired: subscription ended {endDate:d} with no grace period.");
// Send "grace period started" email to company immediately _ = adminNotification.NotifyCompanyExpiredAsync(
await SendEmailIfNotSentAsync(db, emailService, company, today, company.Id, company.CompanyName,
NotificationType.SubscriptionExpiryReminder, company.PrimaryContactEmail ?? string.Empty, endDate);
daysBeforeExpiry: 0, }
gracePeriodDays, else
ct); {
_logger.LogInformation(
"Company {Id} ({Name}) subscription ended. Entering {Days}-day grace period.",
company.Id, company.CompanyName, gracePeriodDays);
// Notify platform admin company.SubscriptionStatus = SubscriptionStatus.GracePeriod;
_ = adminNotification.NotifyCompanyGracePeriodAsync( company.UpdatedAt = DateTime.UtcNow;
company.Id, company.CompanyName, company.UpdatedBy = "System";
company.PrimaryContactEmail ?? string.Empty, expiredDate);
await WriteAuditLogAsync(db, company,
$"Auto-moved to GracePeriod: subscription ended {endDate:d}. Grace period expires {expiredDate:d}.");
await SendEmailIfNotSentAsync(db, emailService, company, today,
NotificationType.SubscriptionExpiryReminder,
daysBeforeExpiry: 0,
gracePeriodDays,
ct);
_ = adminNotification.NotifyCompanyGracePeriodAsync(
company.Id, company.CompanyName,
company.PrimaryContactEmail ?? string.Empty, expiredDate);
}
} }
// ── Reminder emails (only while still Active) ──────────────────── // ── Reminder emails (only while still Active) ────────────────────
@@ -56,15 +56,16 @@ public class InventoryController : Controller
/// <summary> /// <summary>
/// Displays the paginated inventory list with optional keyword search, category filter, /// Displays the paginated inventory list with optional keyword search, category filter,
/// and a low-stock quick-filter. When lowStockOnly is active the default sort switches /// color family filter, and a low-stock quick-filter. When lowStockOnly is active the
/// to QuantityOnHand ascending so the most depleted items surface immediately. Stats /// default sort switches to QuantityOnHand ascending so the most depleted items surface
/// (total value, active count, low-stock count) are computed directly on the DbSet /// immediately. Stats (total value, active count, low-stock count) are computed directly
/// using aggregate SQL to avoid loading all rows into memory. /// on the DbSet using aggregate SQL to avoid loading all rows into memory.
/// </summary> /// </summary>
public async Task<IActionResult> Index( public async Task<IActionResult> Index(
string? searchTerm, string? searchTerm,
string? category, string? category,
string? location, string? location,
string? colorFamily,
string? sortColumn, string? sortColumn,
string sortDirection = "asc", string sortDirection = "asc",
bool lowStockOnly = false, bool lowStockOnly = false,
@@ -88,64 +89,35 @@ public class InventoryController : Controller
}; };
gridRequest.Validate(); gridRequest.Validate();
// Build filter — compose search, category, location, and low-stock predicates var hasSearch = !string.IsNullOrWhiteSpace(searchTerm);
var hasCategory = !string.IsNullOrWhiteSpace(category);
var hasLocation = !string.IsNullOrWhiteSpace(location);
var hasColorFamily = !string.IsNullOrWhiteSpace(colorFamily);
var search = searchTerm?.ToLower() ?? "";
var cat = category ?? "";
var loc = location ?? "";
var colorFam = colorFamily ?? "";
// Single composable predicate — EF Core evaluates the captured booleans as constants
// so inactive conditions fold to true and are omitted from the generated SQL WHERE clause.
System.Linq.Expressions.Expression<Func<InventoryItem, bool>>? filter = null; System.Linq.Expressions.Expression<Func<InventoryItem, bool>>? filter = null;
if (hasSearch || hasCategory || hasLocation || hasColorFamily || lowStockOnly)
var hasSearch = !string.IsNullOrWhiteSpace(searchTerm); {
var hasCategory = !string.IsNullOrWhiteSpace(category); filter = i =>
var hasLocation = !string.IsNullOrWhiteSpace(location); (!lowStockOnly || (i.IsActive && i.QuantityOnHand <= i.ReorderPoint)) &&
(!hasSearch || (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
var search = searchTerm?.ToLower() ?? "";
var cat = category ?? "";
var loc = location ?? "";
if (lowStockOnly && hasSearch && hasLocation)
filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint
&& (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 (hasSearch && hasCategory && hasLocation)
filter = i => (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|| (i.Description != null && i.Description.ToLower().Contains(search)) || (i.Description != null && i.Description.ToLower().Contains(search))
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search)) || (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search))) || (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)))) &&
&& i.Category.ToLower() == cat.ToLower() (!hasCategory || i.Category.ToLower() == cat.ToLower()) &&
&& (i.Location != null && i.Location.ToLower() == loc.ToLower()); (!hasLocation || (i.Location != null && i.Location.ToLower() == loc.ToLower())) &&
else if (hasSearch && hasCategory) (!hasColorFamily || (i.ColorFamilies != null && (
filter = i => (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search) i.ColorFamilies == colorFam ||
|| (i.Description != null && i.Description.ToLower().Contains(search)) i.ColorFamilies.StartsWith(colorFam + ",") ||
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search)) i.ColorFamilies.EndsWith("," + colorFam) ||
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search))) i.ColorFamilies.Contains("," + colorFam + ","))));
&& i.Category.ToLower() == cat.ToLower(); }
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 (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 // Build orderBy function
Func<IQueryable<InventoryItem>, IOrderedQueryable<InventoryItem>> orderBy = gridRequest.SortColumn switch Func<IQueryable<InventoryItem>, IOrderedQueryable<InventoryItem>> orderBy = gridRequest.SortColumn switch
@@ -179,6 +151,14 @@ public class InventoryController : Controller
var allItems = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList(); 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.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.Locations = allItems.Select(i => i.Location).Where(l => !string.IsNullOrWhiteSpace(l)).Distinct().OrderBy(l => l).ToList();
ViewBag.ColorFamilies = allItems
.Where(i => !string.IsNullOrEmpty(i.ColorFamilies))
.SelectMany(i => i.ColorFamilies!.Split(',', StringSplitOptions.RemoveEmptyEntries))
.Select(f => f.Trim())
.Where(f => f.Length > 0)
.Distinct()
.OrderBy(f => f)
.ToList();
ViewBag.StatsLowStockCount = allItems.Count(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint); ViewBag.StatsLowStockCount = allItems.Count(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
ViewBag.StatsActiveCount = allItems.Count(i => i.IsActive); ViewBag.StatsActiveCount = allItems.Count(i => i.IsActive);
ViewBag.StatsTotalValue = allItems.Sum(i => (decimal?)i.QuantityOnHand * i.UnitCost) ?? 0m; ViewBag.StatsTotalValue = allItems.Sum(i => (decimal?)i.QuantityOnHand * i.UnitCost) ?? 0m;
@@ -187,6 +167,7 @@ public class InventoryController : Controller
ViewBag.SearchTerm = searchTerm; ViewBag.SearchTerm = searchTerm;
ViewBag.Category = category; ViewBag.Category = category;
ViewBag.Location = location; ViewBag.Location = location;
ViewBag.ColorFamily = colorFamily;
ViewBag.LowStockOnly = lowStockOnly; ViewBag.LowStockOnly = lowStockOnly;
ViewBag.SortColumn = gridRequest.SortColumn; ViewBag.SortColumn = gridRequest.SortColumn;
ViewBag.SortDirection = gridRequest.SortDirection; ViewBag.SortDirection = gridRequest.SortDirection;
@@ -215,8 +215,23 @@ public class VendorsController : Controller
try try
{ {
var currentUser = await _userManager.GetUserAsync(User); var currentUser = await _userManager.GetUserAsync(User);
var companyId = currentUser!.CompanyId;
var duplicate = await _unitOfWork.Vendors.FirstOrDefaultAsync(
v => v.CompanyId == companyId && v.CompanyName.ToLower() == dto.CompanyName.ToLower());
if (duplicate != null)
{
var msg = $"A vendor named '{dto.CompanyName}' already exists.";
if (inline)
return Json(new { success = false, errors = new[] { msg } });
ModelState.AddModelError(nameof(dto.CompanyName), msg);
await PopulateExpenseAccountsAsync();
await PopulateVendorCategoriesAsync(dto.CategoryIds);
return View(dto);
}
var vendor = _mapper.Map<Vendor>(dto); var vendor = _mapper.Map<Vendor>(dto);
vendor.CompanyId = currentUser!.CompanyId; vendor.CompanyId = companyId;
if (dto.CategoryIds.Any()) if (dto.CategoryIds.Any())
{ {
@@ -1,8 +1,8 @@
@model PowderCoating.Application.DTOs.Accounting.AccountLedgerDto @model PowderCoating.Application.DTOs.Accounting.AccountLedgerDto
@using PowderCoating.Core.Enums @using PowderCoating.Core.Enums
@{ @{
ViewData["Title"] = $"Ledger &mdash; {Model.AccountNumber} {Model.Name}"; ViewData["Title"] = $"Ledger - {Model.AccountNumber} {Model.Name}";
ViewData["PageIcon"] = "bi-journal-text"; ViewData["PageIcon"] = "bi-journal-text";
ViewData["PageHelpTitle"] = "Account Ledger"; ViewData["PageHelpTitle"] = "Account Ledger";
ViewData["PageHelpContent"] = "A chronological list of every transaction posted to this account. Click any Reference to open the source record. Debit increases asset and expense accounts; credit increases liability, equity, and revenue accounts. Use the date range or quick buttons (This Month, YTD, etc.) to narrow the view."; ViewData["PageHelpContent"] = "A chronological list of every transaction posted to this account. Click any Reference to open the source record. Debit increases asset and expense accounts; credit increases liability, equity, and revenue accounts. Use the date range or quick buttons (This Month, YTD, etc.) to narrow the view.";
@@ -1,7 +1,7 @@
@model PowderCoating.Core.Entities.BankReconciliation @model PowderCoating.Core.Entities.BankReconciliation
@using PowderCoating.Web.Controllers @using PowderCoating.Web.Controllers
@{ @{
ViewData["Title"] = $"Reconciliation Report &ndash; {Model.Account?.Name}"; ViewData["Title"] = $"Reconciliation Report - {Model.Account?.Name}";
var clearedDeposits = ViewBag.ClearedDeposits as IEnumerable<PowderCoating.Core.Entities.Payment> ?? Enumerable.Empty<PowderCoating.Core.Entities.Payment>(); var clearedDeposits = ViewBag.ClearedDeposits as IEnumerable<PowderCoating.Core.Entities.Payment> ?? Enumerable.Empty<PowderCoating.Core.Entities.Payment>();
var clearedPayments = ViewBag.ClearedPayments as List<ReconciliationItem> ?? new(); var clearedPayments = ViewBag.ClearedPayments as List<ReconciliationItem> ?? new();
} }
@@ -1,8 +1,8 @@
@using PowderCoating.Web.Controllers @using PowderCoating.Web.Controllers
@model BudgetCreateVm @model BudgetCreateVm
@{ @{
ViewData["Title"] = $"Edit Budget &mdash; {Model.Name}"; ViewData["Title"] = $"Edit Budget - {Model.Name}";
ViewData["PageIcon"] = "bi-pencil"; ViewData["PageIcon"] = "bi-pencil";
var months = new[] { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; var months = new[] { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };
} }
@@ -1,6 +1,6 @@
@model PowderCoating.Application.DTOs.Notification.NotificationTemplateDto @model PowderCoating.Application.DTOs.Notification.NotificationTemplateDto
@{ @{
ViewData["Title"] = $"Edit Template &mdash; {Model.DisplayName}"; ViewData["Title"] = $"Edit Template - {Model.DisplayName}";
ViewData["PageIcon"] = "bi-envelope-gear"; ViewData["PageIcon"] = "bi-envelope-gear";
var placeholders = ViewBag.Placeholders as List<(string Placeholder, string Description)> var placeholders = ViewBag.Placeholders as List<(string Placeholder, string Description)>
?? new List<(string, string)>(); ?? new List<(string, string)>();
@@ -1,6 +1,6 @@
@model PowderCoating.Application.DTOs.Accounting.CustomerStatementDto @model PowderCoating.Application.DTOs.Accounting.CustomerStatementDto
@{ @{
ViewData["Title"] = $"Statement &ndash; {Model.CustomerName}"; ViewData["Title"] = $"Statement - {Model.CustomerName}";
} }
<div class="d-flex justify-content-between align-items-start mb-4 flex-wrap gap-2"> <div class="d-flex justify-content-between align-items-start mb-4 flex-wrap gap-2">
@@ -124,8 +124,9 @@
@{ @{
var lowStockOnly = (bool)(ViewBag.LowStockOnly ?? false); var lowStockOnly = (bool)(ViewBag.LowStockOnly ?? false);
var activeLocation = ViewBag.Location as string; var activeLocation = ViewBag.Location as string;
var activeColorFamily = ViewBag.ColorFamily as string;
} }
@if (!string.IsNullOrEmpty(ViewBag.SearchTerm) || !string.IsNullOrEmpty(ViewBag.Category) || !string.IsNullOrEmpty(activeLocation) || lowStockOnly) @if (!string.IsNullOrEmpty(ViewBag.SearchTerm) || !string.IsNullOrEmpty(ViewBag.Category) || !string.IsNullOrEmpty(activeLocation) || !string.IsNullOrEmpty(activeColorFamily) || lowStockOnly)
{ {
<div class="alert @(lowStockOnly ? "alert-warning" : "alert-info") alert-permanent d-flex justify-content-between align-items-center flex-wrap gap-2"> <div class="alert @(lowStockOnly ? "alert-warning" : "alert-info") alert-permanent d-flex justify-content-between align-items-center flex-wrap gap-2">
<div> <div>
@@ -149,6 +150,10 @@
{ {
<span> in bin "<strong>@activeLocation</strong>"</span> <span> in bin "<strong>@activeLocation</strong>"</span>
} }
@if (!string.IsNullOrEmpty(activeColorFamily))
{
<span> in color family "<strong>@activeColorFamily</strong>"</span>
}
} }
</div> </div>
<div class="d-flex gap-2 flex-wrap"> <div class="d-flex gap-2 flex-wrap">
@@ -182,6 +187,16 @@
<option value="@cat" selected="@(cat == ViewBag.Category)">@cat</option> <option value="@cat" selected="@(cat == ViewBag.Category)">@cat</option>
} }
</select> </select>
@if (((IEnumerable<string>)ViewBag.ColorFamilies).Any())
{
<select name="colorFamily" class="form-select" style="max-width: 160px; min-width: 120px;" onchange="this.form.submit()">
<option value="">All Colors</option>
@foreach (var family in ViewBag.ColorFamilies)
{
<option value="@family" selected="@(family == activeColorFamily)">@family</option>
}
</select>
}
@if (((IEnumerable<string?>)ViewBag.Locations).Any()) @if (((IEnumerable<string?>)ViewBag.Locations).Any())
{ {
<select name="location" class="form-select" style="max-width: 180px; min-width: 130px;" onchange="this.form.submit()"> <select name="location" class="form-select" style="max-width: 180px; min-width: 130px;" onchange="this.form.submit()">
@@ -215,7 +230,7 @@
<div class="card-body p-0"> <div class="card-body p-0">
@if (!Model.Items.Any()) @if (!Model.Items.Any())
{ {
var isInventoryFiltered = !string.IsNullOrEmpty(ViewBag.SearchTerm as string) || !string.IsNullOrEmpty(ViewBag.Category as string) || lowStockOnly; var isInventoryFiltered = !string.IsNullOrEmpty(ViewBag.SearchTerm as string) || !string.IsNullOrEmpty(ViewBag.Category as string) || !string.IsNullOrEmpty(activeColorFamily) || lowStockOnly;
<div class="text-center py-5"> <div class="text-center py-5">
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i> <i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
<h5 class="mt-3 text-muted">No inventory items found</h5> <h5 class="mt-3 text-muted">No inventory items found</h5>
@@ -1,6 +1,6 @@
@model PowderCoating.Application.DTOs.Inventory.InventoryItemDto @model PowderCoating.Application.DTOs.Inventory.InventoryItemDto
@{ @{
ViewData["Title"] = $"Label &mdash; {Model.Name}"; ViewData["Title"] = $"Label - {Model.Name}";
Layout = null; // standalone print page Layout = null; // standalone print page
} }
<!DOCTYPE html> <!DOCTYPE html>
@@ -95,6 +95,12 @@
color: #333; color: #333;
} }
.label-location {
font-size: 14px;
font-weight: 700;
color: #111;
}
.label-scan-hint { .label-scan-hint {
font-size: 9px; font-size: 9px;
color: #888; color: #888;
@@ -158,6 +164,11 @@
<div class="label-sku" style="color:#777">@Model.Manufacturer</div> <div class="label-sku" style="color:#777">@Model.Manufacturer</div>
} }
@if (!string.IsNullOrEmpty(Model.Location))
{
<div class="label-location">Location: @Model.Location</div>
}
<div class="label-scan-hint"> <div class="label-scan-hint">
Scan to log usage &bull; Powder Coating Logix Scan to log usage &bull; Powder Coating Logix
</div> </div>
@@ -1,4 +1,4 @@
@using PowderCoating.Application.DTOs.Inventory @using PowderCoating.Application.DTOs.Inventory
@using PowderCoating.Web.Controllers @using PowderCoating.Web.Controllers
@{ @{
var item = ViewBag.ItemDto as InventoryItemDto; var item = ViewBag.ItemDto as InventoryItemDto;
@@ -6,7 +6,7 @@
var otherJobs = ViewBag.OtherJobs as List<ScanJobOption> ?? new(); var otherJobs = ViewBag.OtherJobs as List<ScanJobOption> ?? new();
var preselectedJobId = ViewBag.PreselectedJobId as int?; var preselectedJobId = ViewBag.PreselectedJobId as int?;
var scanError = ViewBag.ScanError as string; var scanError = ViewBag.ScanError as string;
ViewData["Title"] = $"Log Usage &mdash; {item?.Name}"; ViewData["Title"] = $"Log Usage - {item?.Name}";
Layout = null; // mobile-first standalone page Layout = null; // mobile-first standalone page
} }
<!DOCTYPE html> <!DOCTYPE html>
@@ -1,8 +1,8 @@
@model PowderCoating.Application.DTOs.Job.JobEditItemsViewModel @model PowderCoating.Application.DTOs.Job.JobEditItemsViewModel
@using PowderCoating.Core.Entities @using PowderCoating.Core.Entities
@{ @{
ViewData["Title"] = $"Edit Items &mdash; {Model.JobNumber}"; ViewData["Title"] = $"Edit Items - {Model.JobNumber}";
ViewData["PageIcon"] = "bi-list-check"; ViewData["PageIcon"] = "bi-list-check";
} }
@@ -1,7 +1,7 @@
@model (PowderCoating.Application.DTOs.Job.JobDto Job, PowderCoating.Application.DTOs.Job.IntakeJobDto Form) @model (PowderCoating.Application.DTOs.Job.JobDto Job, PowderCoating.Application.DTOs.Job.IntakeJobDto Form)
@{ @{
ViewData["Title"] = $"Part Intake &mdash; {Model.Job.JobNumber}"; ViewData["Title"] = $"Part Intake - {Model.Job.JobNumber}";
ViewData["PageIcon"] = "bi-box-seam"; ViewData["PageIcon"] = "bi-box-seam";
var job = Model.Job; var job = Model.Job;
var form = Model.Form; var form = Model.Form;
@@ -1,8 +1,8 @@
@using PowderCoating.Application.DTOs.PurchaseOrder @using PowderCoating.Application.DTOs.PurchaseOrder
@model ReceivePurchaseOrderDto @model ReceivePurchaseOrderDto
@{ @{
ViewData["Title"] = $"Receive Goods &mdash; {ViewBag.PoNumber}"; ViewData["Title"] = $"Receive Goods - {ViewBag.PoNumber}";
int poId = (int)ViewBag.PoId; int poId = (int)ViewBag.PoId;
} }
@@ -1,7 +1,7 @@
@using PowderCoating.Application.DTOs.Wizard @using PowderCoating.Application.DTOs.Wizard
@model WizardStep1Dto @model WizardStep1Dto
@{ @{
ViewData["Title"] = "Setup Wizard &mdash; Company Profile"; ViewData["Title"] = "Setup Wizard - Company Profile";
var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto(); var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto();
int step = ViewBag.Step as int? ?? 1; int step = ViewBag.Step as int? ?? 1;
} }
@@ -1,7 +1,7 @@
@using PowderCoating.Application.DTOs.Wizard @using PowderCoating.Application.DTOs.Wizard
@model WizardStep9Dto @model WizardStep9Dto
@{ @{
ViewData["Title"] = "Setup Wizard &mdash; Team Members"; ViewData["Title"] = "Setup Wizard - Team Members";
var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto(); var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto();
int step = ViewBag.Step as int? ?? 10; int step = ViewBag.Step as int? ?? 10;
} }
@@ -1,7 +1,7 @@
@using PowderCoating.Application.DTOs.Wizard @using PowderCoating.Application.DTOs.Wizard
@model WizardStep6Dto @model WizardStep6Dto
@{ @{
ViewData["Title"] = "Setup Wizard &mdash; Chart of Accounts"; ViewData["Title"] = "Setup Wizard - Chart of Accounts";
var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto(); var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto();
int step = ViewBag.Step as int? ?? 11; int step = ViewBag.Step as int? ?? 11;
} }
@@ -1,7 +1,7 @@
@using PowderCoating.Application.DTOs.Wizard @using PowderCoating.Application.DTOs.Wizard
@model WizardStep10Dto @model WizardStep10Dto
@{ @{
ViewData["Title"] = "Setup Wizard &mdash; Vendors & Suppliers"; ViewData["Title"] = "Setup Wizard - Vendors & Suppliers";
var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto(); var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto();
int step = ViewBag.Step as int? ?? 12; int step = ViewBag.Step as int? ?? 12;
} }
@@ -1,7 +1,7 @@
@using PowderCoating.Application.DTOs.Wizard @using PowderCoating.Application.DTOs.Wizard
@model WizardStep8Dto @model WizardStep8Dto
@{ @{
ViewData["Title"] = "Setup Wizard &mdash; Inventory / Powder Colors"; ViewData["Title"] = "Setup Wizard - Inventory / Powder Colors";
var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto(); var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto();
int step = ViewBag.Step as int? ?? 13; int step = ViewBag.Step as int? ?? 13;
} }
@@ -1,7 +1,7 @@
@using PowderCoating.Application.DTOs.Wizard @using PowderCoating.Application.DTOs.Wizard
@model WizardOvensStepDto @model WizardOvensStepDto
@{ @{
ViewData["Title"] = "Setup Wizard &mdash; Equipment & Ovens"; ViewData["Title"] = "Setup Wizard - Equipment & Ovens";
var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto(); var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto();
int step = ViewBag.Step as int? ?? 14; int step = ViewBag.Step as int? ?? 14;
} }
@@ -1,7 +1,7 @@
@using PowderCoating.Application.DTOs.Wizard @using PowderCoating.Application.DTOs.Wizard
@model WizardPricingTiersStepDto @model WizardPricingTiersStepDto
@{ @{
ViewData["Title"] = "Setup Wizard &mdash; Pricing Tiers"; ViewData["Title"] = "Setup Wizard - Pricing Tiers";
var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto(); var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto();
int step = ViewBag.Step as int? ?? 15; int step = ViewBag.Step as int? ?? 15;
} }
@@ -1,7 +1,7 @@
@using PowderCoating.Application.DTOs.Wizard @using PowderCoating.Application.DTOs.Wizard
@model WizardCatalogStepDto @model WizardCatalogStepDto
@{ @{
ViewData["Title"] = "Setup Wizard &mdash; Service Catalog"; ViewData["Title"] = "Setup Wizard - Service Catalog";
var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto(); var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto();
int step = ViewBag.Step as int? ?? 16; int step = ViewBag.Step as int? ?? 16;
} }
@@ -1,7 +1,7 @@
@using PowderCoating.Application.DTOs.Wizard @using PowderCoating.Application.DTOs.Wizard
@model WizardStep7Dto @model WizardStep7Dto
@{ @{
ViewData["Title"] = "Setup Wizard &mdash; Notifications"; ViewData["Title"] = "Setup Wizard - Notifications";
var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto(); var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto();
int step = ViewBag.Step as int? ?? 17; int step = ViewBag.Step as int? ?? 17;
} }
@@ -1,7 +1,7 @@
@using PowderCoating.Application.DTOs.Wizard @using PowderCoating.Application.DTOs.Wizard
@model WizardStep9Dto @model WizardStep9Dto
@{ @{
ViewData["Title"] = "Setup Wizard &mdash; Team Members"; ViewData["Title"] = "Setup Wizard - Team Members";
var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto(); var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto();
int step = ViewBag.Step as int? ?? 18; int step = ViewBag.Step as int? ?? 18;
} }
@@ -1,7 +1,7 @@
@using PowderCoating.Application.DTOs.Wizard @using PowderCoating.Application.DTOs.Wizard
@model WizardStep2QbDto @model WizardStep2QbDto
@{ @{
ViewData["Title"] = "Setup Wizard &mdash; QuickBooks Migration"; ViewData["Title"] = "Setup Wizard - QuickBooks Migration";
var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto(); var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto();
int step = ViewBag.Step as int? ?? 2; int step = ViewBag.Step as int? ?? 2;
} }
@@ -1,8 +1,8 @@
@using PowderCoating.Application.DTOs.Wizard @using PowderCoating.Application.DTOs.Wizard
@using PowderCoating.Core.Enums @using PowderCoating.Core.Enums
@model WizardStep2Dto @model WizardStep2Dto
@{ @{
ViewData["Title"] = "Setup Wizard &mdash; Operating Costs"; ViewData["Title"] = "Setup Wizard - Operating Costs";
var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto(); var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto();
int step = ViewBag.Step as int? ?? 3; int step = ViewBag.Step as int? ?? 3;
} }
@@ -1,7 +1,7 @@
@using PowderCoating.Application.DTOs.Wizard @using PowderCoating.Application.DTOs.Wizard
@model WizardOvensStepDto @model WizardOvensStepDto
@{ @{
ViewData["Title"] = "Setup Wizard &mdash; Shop Equipment"; ViewData["Title"] = "Setup Wizard - Shop Equipment";
var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto(); var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto();
int step = ViewBag.Step as int? ?? 4; int step = ViewBag.Step as int? ?? 4;
} }
@@ -1,7 +1,7 @@
@using PowderCoating.Application.DTOs.Wizard @using PowderCoating.Application.DTOs.Wizard
@model WizardStep3Dto @model WizardStep3Dto
@{ @{
ViewData["Title"] = "Setup Wizard &mdash; Document Numbering"; ViewData["Title"] = "Setup Wizard - Document Numbering";
var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto(); var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto();
int step = ViewBag.Step as int? ?? 5; int step = ViewBag.Step as int? ?? 5;
} }
@@ -1,7 +1,7 @@
@using PowderCoating.Application.DTOs.Wizard @using PowderCoating.Application.DTOs.Wizard
@model WizardStep5Dto @model WizardStep5Dto
@{ @{
ViewData["Title"] = "Setup Wizard &mdash; Job Settings"; ViewData["Title"] = "Setup Wizard - Job Settings";
var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto(); var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto();
int step = ViewBag.Step as int? ?? 6; int step = ViewBag.Step as int? ?? 6;
} }
@@ -1,7 +1,7 @@
@using PowderCoating.Application.DTOs.Wizard @using PowderCoating.Application.DTOs.Wizard
@model WizardStep4Dto @model WizardStep4Dto
@{ @{
ViewData["Title"] = "Setup Wizard &mdash; Payment Terms"; ViewData["Title"] = "Setup Wizard - Payment Terms";
var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto(); var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto();
int step = ViewBag.Step as int? ?? 7; int step = ViewBag.Step as int? ?? 7;
} }
@@ -1,7 +1,7 @@
@using PowderCoating.Application.DTOs.Wizard @using PowderCoating.Application.DTOs.Wizard
@model WizardPricingTiersStepDto @model WizardPricingTiersStepDto
@{ @{
ViewData["Title"] = "Setup Wizard &mdash; Pricing Tiers"; ViewData["Title"] = "Setup Wizard - Pricing Tiers";
var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto(); var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto();
int step = ViewBag.Step as int? ?? 8; int step = ViewBag.Step as int? ?? 8;
} }
@@ -1,7 +1,7 @@
@using PowderCoating.Application.DTOs.Wizard @using PowderCoating.Application.DTOs.Wizard
@model WizardStep7Dto @model WizardStep7Dto
@{ @{
ViewData["Title"] = "Setup Wizard &mdash; Notifications"; ViewData["Title"] = "Setup Wizard - Notifications";
var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto(); var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto();
int step = ViewBag.Step as int? ?? 5; int step = ViewBag.Step as int? ?? 5;
} }
@@ -1,4 +1,4 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-bs-theme="light"> <html lang="en" data-bs-theme="light">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
@@ -83,4 +83,12 @@
</nav> </nav>
</div> </div>
</div> </div>
<script>
function changePageSize(size) {
var url = new URL(window.location.href);
url.searchParams.set('pageSize', size);
url.searchParams.set('pageNumber', '1');
window.location.href = url.toString();
}
</script>
} }
@@ -1,7 +1,7 @@
@using PowderCoating.Core.Entities @using PowderCoating.Core.Entities
@model StripeWebhookEvent @model StripeWebhookEvent
@{ @{
ViewData["Title"] = $"Webhook Event &ndash; {Model.EventId}"; ViewData["Title"] = $"Webhook Event - {Model.EventId}";
var statusClass = Model.Status switch var statusClass = Model.Status switch
{ {
StripeWebhookEventStatus.Processed => "success", StripeWebhookEventStatus.Processed => "success",
@@ -2,7 +2,7 @@
@using PowderCoating.Core.Enums @using PowderCoating.Core.Enums
@model Company @model Company
@{ @{
ViewData["Title"] = $"Manage &ndash; {Model.CompanyName}"; ViewData["Title"] = $"Manage - {Model.CompanyName}";
var planConfigs = (dynamic)ViewBag.PlanConfigs; var planConfigs = (dynamic)ViewBag.PlanConfigs;
string PlanName(int plan) string PlanName(int plan)
@@ -1,6 +1,6 @@
@model PowderCoating.Application.DTOs.Accounting.VendorStatementDto @model PowderCoating.Application.DTOs.Accounting.VendorStatementDto
@{ @{
ViewData["Title"] = $"Statement &ndash; {Model.VendorName}"; ViewData["Title"] = $"Statement - {Model.VendorName}";
} }
<div class="d-flex justify-content-between align-items-start mb-4 flex-wrap gap-2"> <div class="d-flex justify-content-between align-items-start mb-4 flex-wrap gap-2">
@@ -88,6 +88,20 @@
border-bottom: 3px solid var(--bs-primary); border-bottom: 3px solid var(--bs-primary);
} }
/* ── Dark mode fix for main settings tabs: UA button styling bleeds through
without an explicit background-color, producing white buttons with faint text. */
[data-bs-theme="dark"] #settingsTabs .nav-link {
background-color: transparent;
}
[data-bs-theme="dark"] #settingsTabs .nav-link:hover:not(.active) {
background-color: var(--bs-tertiary-bg);
}
[data-bs-theme="dark"] #settingsTabs .nav-link.active {
background-color: var(--bs-body-bg);
}
/* ── PDF Templates inner tabs (card-header-tabs) ────────────────────────── */ /* ── PDF Templates inner tabs (card-header-tabs) ────────────────────────── */
#pdfTemplateTabs .nav-link { #pdfTemplateTabs .nav-link {
color: var(--bs-secondary-color); color: var(--bs-secondary-color);