Compare commits

..

13 Commits

Author SHA1 Message Date
spouliot 8acbc8605d Harden multi-tenant isolation across all user-facing controllers
Added explicit CompanyId == companyId predicates to every tenant-scoped
query in 22 controllers so cross-tenant data leakage is impossible even
if EF Core global query filters are bypassed or misconfigured.

Also fixed ApplicationDbContext.IsPlatformAdmin to correctly return true
for SuperAdmins with no CompanyId claim (break-glass accounts) and when
no HTTP context is present (background services, unit tests), resolving
225 unit test failures that stemmed from the global filter blocking all
in-memory test data.

New MultiTenantIsolationTests class (8 tests) verifies the explicit
predicate layer independently of the global query filters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 18:04:22 -04:00
spouliot 485f0b69c8 Format Log Material dropdown as 'Manufacturer - Name (UoM)'
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:51:32 -04:00
spouliot f380c152ca Promote job powders to top of Log Material dropdown
Powders already assigned to this job's coats appear under a 'This Job'
section header, then a divider, then 'All Inventory' — so the most
relevant choices are always one click away.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:49:46 -04:00
spouliot 79c8c7e6a4 Add manufacturer to Log Material item combobox
Shows manufacturer name as muted secondary text in each dropdown row
and includes it in the search filter, so users can find a powder by
brand when multiple items share a similar name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:46:48 -04:00
spouliot 6cf355071b Replace Log Material item dropdown with searchable combobox
Inventory lists grow over time; a plain <select> becomes unusable. The
new combobox filters as you type, supports keyboard navigation
(Arrow/Enter/Escape), and shows current stock on selection — matching
the pattern used by the powder picker in the item wizard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:41:14 -04:00
spouliot ebd474ae81 Fix log material dropdown showing undefined - camelCase JSON serialization
System.Text.Json defaults to PascalCase; JS reads camelCase. Add
JsonNamingPolicy.CamelCase to the InventoryItemsForModal serialization.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:15:23 -04:00
spouliot 3c390a2e05 Merge branch 'dev' - invoice fixes, log material modal, complete job UX 2026-05-16 15:38:05 -04:00
spouliot 0df2353d4f Complete Job modal: ask powder usage once per color, not per item/coat
The modal was showing one row per coat per item, so a job with 5 items
each with 2 coats of the same powder produced 10 identical input rows.

Now groups by unique InventoryItemId and shows one row per powder color
for the whole job. The controller distributes the entered total across
coats proportionally by their estimated PowderToOrder so per-coat
reporting data is preserved. A single inventory transaction is created
per powder (net of any pre-logged scan credit).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 12:30:30 -04:00
spouliot be0a5b26e2 Update AI assistant and help docs for invoice and material logging changes
- HelpKnowledgeBase: invoice-from-job now mentions discount carried over,
  Discount Applied display row, and negative line items; new entry for
  PC-based Log Material modal on job details
- Help/Invoices.cshtml: from-job steps updated with discount/terms/due date
  pre-fill detail; sending section corrects due date source (quote/customer)
- Help/Jobs.cshtml: new "Logging Material Usage from a PC" section documenting
  the Log Material modal alongside the existing QR scan instructions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 12:15:20 -04:00
spouliot 36680eced9 Add manual Log Material modal to job details page
PC users were blocked to QR scan only for logging material usage. Now a
"Log Material" button opens an inline modal with:
- Inventory item dropdown (name + unit of measure, current stock shown on select)
- Entry method toggle: "Amount Used" or "Amount Remaining" (computes used = onHand - remaining)
- Reason: Job Usage or Waste/Spillage
- Notes field
Submits via AJAX to Jobs/LogMaterial (new POST action) which mirrors the
InventoryController.LogUsage flow — updates QuantityOnHand, creates InventoryTransaction,
posts GL entries (DR COGS / CR Inventory). QR scan button retained as icon.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 12:10:54 -04:00
spouliot 27aa4e0ea6 Invoice create: show discount row in totals, allow negative line items
- Add "Discount Applied" display row (red, hidden when zero) between subtotal
  and tax so users can see the discount being deducted at a glance
- Remove min="0" from UnitPrice and TotalPrice inputs (server-rendered and JS
  template) so negative adjustment lines can be entered without form rejection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 11:41:47 -04:00
spouliot b2d6fae400 Fix failing test: revert quote-based discount to use sourceQuote.DiscountAmount
The quote discount must come from the agreed quote price, not the job's pricing
snapshot (which may have DiscountAmount=0 for legacy or unset reasons). The job
snapshot fix only applies to direct jobs where no source quote exists.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 11:29:12 -04:00
spouliot 3a1928f9bf Fix invoice creation from job: discount ignored, wrong due date, wrong terms
- DueDate was computed from DefaultTurnaroundDays (a shop ops setting) instead
  of from the payment terms string; now uses PaymentTermsParser throughout
- Discount was never applied for direct jobs (PricingBreakdownJson was read for
  fees but DiscountAmount was silently skipped)
- Quote-based jobs used sourceQuote.DiscountAmount, ignoring any discount edits
  made to the job after quote conversion; now prefers the job's pricing snapshot
- Payment terms and due date now inherit from sourceQuote.Terms → customer.PaymentTerms
  → company default, so the invoice reflects the agreed or customer-specific terms
- EarlyPaymentDiscount fields now populated from inherited terms

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 10:45:40 -04:00
31 changed files with 1225 additions and 355 deletions
@@ -389,7 +389,7 @@ public class CompleteJobDto
{
public int JobId { get; set; }
public decimal? ActualTimeSpentHours { get; set; }
public List<JobItemCoatUsageDto> CoatUsages { get; set; } = new();
public List<JobPowderUsageDto> PowderUsages { get; set; } = new();
public bool SendEmailToCustomer { get; set; } = false;
}
@@ -400,10 +400,10 @@ public class SendJobSmsRequest
public string Message { get; set; } = string.Empty;
}
// DTO for tracking actual powder usage per coat
public class JobItemCoatUsageDto
// DTO for tracking actual powder usage per inventory item (color) for the whole job
public class JobPowderUsageDto
{
public int JobItemCoatId { get; set; }
public int InventoryItemId { get; set; }
public decimal? ActualPowderUsedLbs { get; set; }
}
@@ -92,7 +92,11 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
if (companyIdClaim != null && int.TryParse(companyIdClaim, out int companyId))
return companyId;
return null;
// Authenticated but CompanyId claim is missing or invalid.
// Return 0 (never a real company ID) so the global filter generates
// "CompanyId = 0" which matches nothing — prevents null-comparison
// ambiguity from leaking cross-tenant rows.
return 0;
}
}
@@ -129,8 +133,11 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
{
get
{
// No HTTP context means background service, hosted service, or unit test — bypass tenant filter
if (_httpContextAccessor?.HttpContext == null) return true;
if (!IsSuperAdmin) return false;
return CurrentCompanyId == null || CurrentCompanyId == 1;
// CompanyId == 0 means no claim was present (break-glass / test SuperAdmins) — treat as platform admin
return CurrentCompanyId == null || CurrentCompanyId == 0 || CurrentCompanyId == 1;
}
}
@@ -60,10 +60,11 @@ public class AccountingExportController : Controller
{
var start = startDate.Date;
var end = endDate.Date.AddDays(1).AddTicks(-1);
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// ── Load data ─────────────────────────────────────────────────────────
var invoices = (await _unitOfWork.Invoices.FindAsync(
i => i.InvoiceDate >= start && i.InvoiceDate <= end,
i => i.CompanyId == companyId && i.InvoiceDate >= start && i.InvoiceDate <= end,
false,
i => i.InvoiceItems,
i => i.Payments,
@@ -72,7 +73,7 @@ public class AccountingExportController : Controller
.ToList();
var expenses = (await _unitOfWork.Expenses.FindAsync(
e => e.Date >= start && e.Date <= end,
e => e.CompanyId == companyId && e.Date >= start && e.Date <= end,
false,
e => e.Vendor,
e => e.ExpenseAccount,
@@ -82,7 +83,7 @@ public class AccountingExportController : Controller
var bills = await _unitOfWork.Bills.GetForDateRangeAsync(start, end);
var customers = (await _unitOfWork.Customers.GetAllAsync())
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId))
.OrderBy(c => c.CompanyName ?? c.ContactFirstName)
.ToList();
@@ -486,9 +486,12 @@ public class AppointmentsController : Controller
try
{
var events = new List<CalendarEventDto>();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// 1. Fetch appointments in date range
var allAppointments = await _unitOfWork.Appointments.GetAllAsync(false,
var allAppointments = await _unitOfWork.Appointments.FindAsync(
a => a.CompanyId == companyId,
false,
a => a.Customer,
a => a.AppointmentType,
a => a.AppointmentStatus);
@@ -501,7 +504,9 @@ public class AppointmentsController : Controller
events.AddRange(appointmentEvents);
// 2. Fetch maintenance records in date range
var allMaintenanceRecords = await _unitOfWork.MaintenanceRecords.GetAllAsync(false,
var allMaintenanceRecords = await _unitOfWork.MaintenanceRecords.FindAsync(
m => m.CompanyId == companyId,
false,
m => m.Equipment);
var maintenanceRecords = allMaintenanceRecords
@@ -539,7 +544,9 @@ public class AppointmentsController : Controller
}
// 3. Fetch jobs and add as all-day events
var allJobs = await _unitOfWork.Jobs.GetAllAsync(false,
var allJobs = await _unitOfWork.Jobs.FindAsync(
j => j.CompanyId == companyId,
false,
j => j.Customer,
j => j.JobStatus);
@@ -746,13 +753,16 @@ public class AppointmentsController : Controller
try
{
var terminalCodes = new[] { AppConstants.StatusCodes.Job.Completed, AppConstants.StatusCodes.Job.Delivered, AppConstants.StatusCodes.Job.Cancelled };
var allJobs = await _unitOfWork.Jobs.GetAllAsync(false,
var calCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allJobs = await _unitOfWork.Jobs.FindAsync(
j => j.CompanyId == calCompanyId,
false,
j => j.Customer, j => j.JobStatus, j => j.JobItems);
// Load coats separately — filter by JobItemId using already-loaded item IDs
var jobItemIds = allJobs.SelectMany(j => j.JobItems.Select(i => i.Id)).ToList();
var allCoats = await _unitOfWork.JobItemCoats.FindAsync(
c => jobItemIds.Contains(c.JobItemId));
c => jobItemIds.Contains(c.JobItemId) && c.CompanyId == calCompanyId);
var coatsByItemId = allCoats
.Where(c => !c.IsDeleted)
@@ -891,7 +901,9 @@ public class AppointmentsController : Controller
/// </summary>
private async Task PopulateCreateDropdowns()
{
var customers = await _unitOfWork.Customers.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
var customerList = customers.Select(c => new
{
c.Id,
@@ -903,19 +915,16 @@ public class AppointmentsController : Controller
.ToList();
ViewBag.Customers = new SelectList(customerList, "Id", "DisplayName");
// Use cached appointment types
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var types = await _lookupCache.GetAppointmentTypeLookupsAsync(companyId);
ViewBag.AppointmentTypes = new SelectList(types.Where(t => t.IsActive).OrderBy(t => t.DisplayOrder), "Id", "DisplayName");
var companyIdForWorkers = _tenantContext.GetCurrentCompanyId() ?? 0;
var workers = await _userManager.Users
.Where(u => u.CompanyId == companyIdForWorkers && u.IsActive && u.CompanyRole != null)
.Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null)
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
.ToListAsync();
ViewBag.Workers = new SelectList(workers.Select(u => new { u.Id, FullName = u.FullName }), "Id", "FullName");
var jobs = await _unitOfWork.Jobs.GetAllAsync();
var jobs = await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId);
ViewBag.Jobs = new SelectList(jobs.OrderBy(j => j.JobNumber), "Id", "JobNumber");
}
@@ -27,15 +27,18 @@ namespace PowderCoating.Web.Controllers
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly ITenantContext _tenantContext;
private readonly ILogger<CatalogCategoriesController> _logger;
public CatalogCategoriesController(
IUnitOfWork unitOfWork,
IMapper mapper,
ITenantContext tenantContext,
ILogger<CatalogCategoriesController> logger)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_tenantContext = tenantContext;
_logger = logger;
}
@@ -52,8 +55,9 @@ namespace PowderCoating.Web.Controllers
{
try
{
var indexCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var categories = await _unitOfWork.CatalogCategories
.GetAllAsync(false,
.FindAsync(c => c.CompanyId == indexCompanyId, false,
c => c.ParentCategory,
c => c.SubCategories,
c => c.Items);
@@ -164,7 +168,8 @@ namespace PowderCoating.Web.Controllers
if (ModelState.IsValid)
{
// Check for duplicate category name under the same parent (case-insensitive)
var allCategories = await _unitOfWork.CatalogCategories.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allCategories = await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == companyId);
var existingCategory = allCategories.FirstOrDefault(c =>
c.Name.Equals(dto.Name.Trim(), StringComparison.OrdinalIgnoreCase) &&
c.ParentCategoryId == dto.ParentCategoryId);
@@ -272,7 +277,8 @@ namespace PowderCoating.Web.Controllers
if (nameChanged || parentChanged)
{
var allCategories = await _unitOfWork.CatalogCategories.GetAllAsync();
var editCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allCategories = await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == editCompanyId);
var existingCategory = allCategories.FirstOrDefault(c =>
c.Id != id &&
c.Name.Equals(dto.Name.Trim(), StringComparison.OrdinalIgnoreCase) &&
@@ -444,7 +450,8 @@ namespace PowderCoating.Web.Controllers
var trimmedName = request.Name.Trim();
// Check for duplicate category name under the same parent (case-insensitive)
var allCategories = await _unitOfWork.CatalogCategories.GetAllAsync();
var quickCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allCategories = await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == quickCompanyId);
var existingCategory = allCategories.FirstOrDefault(c =>
c.Name.Equals(trimmedName, StringComparison.OrdinalIgnoreCase) &&
c.ParentCategoryId == request.ParentCategoryId);
@@ -500,8 +507,9 @@ namespace PowderCoating.Web.Controllers
{
try
{
var treeCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var categories = await _unitOfWork.CatalogCategories
.GetAllAsync(false, c => c.SubCategories, c => c.Items);
.FindAsync(c => c.CompanyId == treeCompanyId, false, c => c.SubCategories, c => c.Items);
// Build tree from root categories
var rootCategories = categories
@@ -535,7 +543,8 @@ namespace PowderCoating.Web.Controllers
{
try
{
var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList();
var dropdownCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var categories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == dropdownCompanyId)).ToList();
// Build hierarchical list (parents before children)
var hierarchicalList = new List<CatalogCategory>();
@@ -573,7 +582,8 @@ namespace PowderCoating.Web.Controllers
/// </param>
private async Task PopulateParentCategoryDropdown(int? excludeCategoryId = null)
{
var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList();
var parentDropCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var categories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == parentDropCompanyId)).ToList();
// Exclude the current category and its descendants to prevent circular references
var excludedIds = new HashSet<int>();
@@ -700,7 +710,8 @@ namespace PowderCoating.Web.Controllers
if (categoryId == newParentId)
return true;
var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList();
var circleCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var categories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == circleCompanyId)).ToList();
var current = categories.FirstOrDefault(c => c.Id == newParentId);
while (current != null)
@@ -83,7 +83,8 @@ namespace PowderCoating.Web.Controllers
try
{
// Get all categories with their items
var allCategories = (await _unitOfWork.CatalogCategories.GetAllAsync(false, c => c.Items)).ToList();
var itemsCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allCategories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == itemsCompanyId, false, c => c.Items)).ToList();
var allItems = allCategories.SelectMany(c => c.Items).ToList();
// Apply search filter
@@ -578,7 +579,8 @@ namespace PowderCoating.Web.Controllers
return Json(new List<object>());
}
var allItems = await _unitOfWork.CatalogItems.GetAllAsync(false, i => i.Category);
var searchCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allItems = await _unitOfWork.CatalogItems.FindAsync(i => i.CompanyId == searchCompanyId, false, i => i.Category);
var search = searchTerm.ToLower();
var items = allItems
@@ -694,7 +696,8 @@ namespace PowderCoating.Web.Controllers
/// </summary>
private async Task PopulateCategoryDropdown()
{
var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var categories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == companyId)).ToList();
// Build hierarchical list (parents before children)
var hierarchicalList = new List<CatalogCategory>();
@@ -1045,7 +1048,7 @@ namespace PowderCoating.Web.Controllers
// Load all categories so we can build full paths (e.g. "Cerakote > Firearms").
// The full path gives Claude the coating-type context it needs — an item in
// "Firearms" under "Cerakote" costs very differently than one under "Powder Coat".
var allCategories = (await _unitOfWork.CatalogCategories.GetAllAsync())
var allCategories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == currentUser.CompanyId))
.ToDictionary(c => c.Id);
// Load company operating costs
@@ -142,10 +142,10 @@ public class CompanySettingsController : Controller
&& !connectClientId.Contains("your_connect_client_id_here", StringComparison.OrdinalIgnoreCase);
// Load notification templates for inline tab
var existing = await _unitOfWork.NotificationTemplates.GetAllAsync();
var existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);
var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList());
if (seeded > 0)
existing = await _unitOfWork.NotificationTemplates.GetAllAsync();
existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);
dto.NotificationTemplates = existing
.OrderBy(t => (int)t.NotificationType).ThenBy(t => (int)t.Channel)
@@ -755,8 +755,8 @@ public class CompanySettingsController : Controller
var costs = company.OperatingCosts;
var ovens = (await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive)).OrderBy(o => o.DisplayOrder).ToList();
var coatingCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsCoating)).ToList();
var ovens = (await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive && o.CompanyId == companyId.Value)).OrderBy(o => o.DisplayOrder).ToList();
var coatingCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsCoating && c.CompanyId == companyId.Value)).ToList();
var sb = new System.Text.StringBuilder();
@@ -920,7 +920,8 @@ public class CompanySettingsController : Controller
{
try
{
var statuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var statuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == companyId);
var sortedStatuses = statuses.OrderBy(s => s.DisplayOrder).ToList();
var dtos = _mapper.Map<List<JobStatusLookupDto>>(sortedStatuses);
@@ -1071,7 +1072,8 @@ public class CompanySettingsController : Controller
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var statuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId();
var statuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == (companyId ?? 0));
for (int i = 0; i < dto.OrderedIds.Count; i++)
{
@@ -1084,7 +1086,6 @@ public class CompanySettingsController : Controller
}
await _unitOfWork.CompleteAsync();
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId.HasValue) _lookupCache.InvalidateCompanyCache(companyId.Value);
_logger.LogInformation("Job statuses reordered");
@@ -1113,7 +1114,8 @@ public class CompanySettingsController : Controller
{
try
{
var priorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId);
var sortedPriorities = priorities.OrderBy(p => p.DisplayOrder).ToList();
var dtos = _mapper.Map<List<JobPriorityLookupDto>>(sortedPriorities);
@@ -1258,7 +1260,8 @@ public class CompanySettingsController : Controller
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var priorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId);
for (int i = 0; i < dto.OrderedIds.Count; i++)
{
@@ -1297,7 +1300,8 @@ public class CompanySettingsController : Controller
{
try
{
var statuses = await _unitOfWork.QuoteStatusLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var statuses = await _unitOfWork.QuoteStatusLookups.FindAsync(s => s.CompanyId == companyId);
var sortedStatuses = statuses.OrderBy(s => s.DisplayOrder).ToList();
var dtos = _mapper.Map<List<QuoteStatusLookupDto>>(sortedStatuses);
@@ -1478,7 +1482,8 @@ public class CompanySettingsController : Controller
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var statuses = await _unitOfWork.QuoteStatusLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var statuses = await _unitOfWork.QuoteStatusLookups.FindAsync(s => s.CompanyId == companyId);
for (int i = 0; i < dto.OrderedIds.Count; i++)
{
@@ -1517,7 +1522,8 @@ public class CompanySettingsController : Controller
{
try
{
var services = await _unitOfWork.PrepServices.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var services = await _unitOfWork.PrepServices.FindAsync(s => s.CompanyId == companyId);
var sortedServices = services.OrderBy(s => s.DisplayOrder).ToList();
var dtos = _mapper.Map<List<PrepServiceDto>>(sortedServices);
@@ -1639,7 +1645,8 @@ public class CompanySettingsController : Controller
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var services = await _unitOfWork.PrepServices.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var services = await _unitOfWork.PrepServices.FindAsync(s => s.CompanyId == companyId);
for (int i = 0; i < dto.OrderedIds.Count; i++)
{
@@ -1812,7 +1819,8 @@ public class CompanySettingsController : Controller
{
try
{
var types = await _unitOfWork.AppointmentTypeLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var types = await _unitOfWork.AppointmentTypeLookups.FindAsync(t => t.CompanyId == companyId);
var sortedTypes = types.OrderBy(t => t.DisplayOrder).ToList();
var dtos = _mapper.Map<List<AppointmentTypeLookupDto>>(sortedTypes);
@@ -1956,7 +1964,8 @@ public class CompanySettingsController : Controller
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var types = await _unitOfWork.AppointmentTypeLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var types = await _unitOfWork.AppointmentTypeLookups.FindAsync(t => t.CompanyId == companyId);
for (int i = 0; i < dto.OrderedIds.Count; i++)
{
@@ -1996,7 +2005,8 @@ public class CompanySettingsController : Controller
{
try
{
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
var sortedCategories = categories.OrderBy(c => c.DisplayOrder).ToList();
var dtos = _mapper.Map<List<InventoryCategoryLookupDto>>(sortedCategories);
@@ -2132,7 +2142,8 @@ public class CompanySettingsController : Controller
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
for (int i = 0; i < dto.OrderedIds.Count; i++)
{
@@ -2349,12 +2360,12 @@ public class CompanySettingsController : Controller
if (companyId == null) return RedirectToAction(nameof(Index));
// Load all existing templates for this company
var existing = await _unitOfWork.NotificationTemplates.GetAllAsync();
var existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);
// Auto-seed any missing canonical combinations
var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList());
if (seeded > 0)
existing = await _unitOfWork.NotificationTemplates.GetAllAsync();
existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);
var dtos = existing.OrderBy(t => (int)t.NotificationType).ThenBy(t => (int)t.Channel)
.Select(t => new NotificationTemplateDto
@@ -315,7 +315,8 @@ public class CreditMemosController : Controller
private async Task PopulateCustomersAsync(int? selectedId)
{
var customers = await _unitOfWork.Customers.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
ViewBag.Customers = customers
.OrderBy(c => c.CompanyName ?? $"{c.ContactFirstName} {c.ContactLastName}".Trim())
.Select(c => new SelectListItem
@@ -342,14 +342,16 @@ public class DashboardController : Controller
TipOfTheDay = data.TipOfTheDay
};
// Resolve company once so all remaining queries are explicitly scoped
var currentCompanyId = _tenantContext.GetCurrentCompanyId();
var companyId = currentCompanyId ?? 0;
// Dropdowns for the "Add Custom Powder to Inventory" modal
var inventoryCategories = (await _unitOfWork.InventoryCategoryLookups.GetAllAsync())
.Where(c => c.IsActive)
var inventoryCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsActive && c.CompanyId == companyId))
.OrderBy(c => c.DisplayOrder)
.Select(c => new { c.Id, c.DisplayName })
.ToList();
var vendors = (await _unitOfWork.Vendors.GetAllAsync())
.Where(v => v.IsActive)
var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.IsActive && v.CompanyId == companyId))
.OrderBy(v => v.CompanyName)
.Select(v => new { v.Id, v.CompanyName })
.ToList();
@@ -357,7 +359,6 @@ public class DashboardController : Controller
ViewBag.VendorList = vendors;
// Config health check — surface setup gaps to company admins
var currentCompanyId = _tenantContext.GetCurrentCompanyId();
if (currentCompanyId.HasValue)
{
ViewBag.ConfigHealth = await _configHealth.CheckAsync(currentCompanyId.Value);
@@ -711,8 +712,8 @@ public class DashboardController : Controller
i => i.Coats.Any(c => c.Id == coatId), false, i => i.Job);
var companyId = jobItem?.Job?.CompanyId ?? _tenantContext.GetCurrentCompanyId() ?? 0;
// Check SKU uniqueness
if (await _unitOfWork.InventoryItems.AnyAsync(i => i.SKU == sku.Trim()))
// Check SKU uniqueness within this company
if (await _unitOfWork.InventoryItems.AnyAsync(i => i.SKU == sku.Trim() && i.CompanyId == companyId))
return Json(new { success = false, message = $"SKU '{sku}' already exists in inventory." });
// Determine category display name for legacy field
@@ -160,7 +160,8 @@ 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
var allItems = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList();
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.StatsLowStockCount = allItems.Count(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
ViewBag.StatsActiveCount = allItems.Count(i => i.IsActive);
@@ -1106,7 +1107,8 @@ public class InventoryController : Controller
// Build a set of SKUs already in this company's inventory so we can exclude them.
// When editing, the current item's own SKU is re-included so its catalog entry still appears.
var existingItems = await _unitOfWork.InventoryItems.GetAllAsync();
var skuCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var existingItems = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == skuCompanyId);
var existingSkus = existingItems
.Where(i => !string.IsNullOrWhiteSpace(i.ManufacturerPartNumber) && i.Id != (currentId ?? 0))
.Select(i => i.ManufacturerPartNumber!.Trim().ToLower())
@@ -1182,7 +1184,7 @@ public class InventoryController : Controller
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Find the default coating category to assign
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
var coatingCategory = categories
.Where(c => c.IsActive && c.IsCoating)
.OrderBy(c => c.DisplayOrder)
@@ -1369,11 +1371,11 @@ public class InventoryController : Controller
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
ViewBag.AiInventoryAssistEnabled = await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId);
var vendors = await _unitOfWork.Vendors.GetAllAsync();
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");
// Load categories from lookup table
var allCategories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
var allCategories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
var categories = allCategories
.Where(c => c.IsActive)
.OrderBy(c => c.DisplayOrder)
@@ -1738,7 +1740,8 @@ public class InventoryController : Controller
DateTime? dateTo,
string? typeFilter)
{
var allItems = await _unitOfWork.InventoryItems.GetAllAsync();
var ledgerCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allItems = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == ledgerCompanyId);
var itemList = allItems
.Where(i => i.IsActive || i.QuantityOnHand > 0)
.OrderBy(i => i.Name)
@@ -340,13 +340,14 @@ public class InvoicesController : Controller
var costs = await _unitOfWork.CompanyOperatingCosts
.FirstOrDefaultAsync(c => c.CompanyId == currentUser.CompanyId && !c.IsDeleted);
var defaultTerms = prefs?.DefaultPaymentTerms ?? "Net 30";
var dto = new CreateInvoiceDto
{
PreparedById = currentUser.Id,
InvoiceDate = DateTime.Today,
DueDate = DateTime.Today.AddDays(prefs?.DefaultTurnaroundDays ?? 30),
DueDate = PaymentTermsParser.CalculateDueDate(defaultTerms, DateTime.Today),
TaxPercent = costs?.TaxPercent ?? 0,
Terms = prefs?.DefaultPaymentTerms ?? "Net 30"
Terms = defaultTerms
};
if (jobId.HasValue)
@@ -378,6 +379,13 @@ public class InvoicesController : Controller
var defaultRevenueAccount = await _unitOfWork.Accounts
.FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.IsActive);
// Deserialize the job's pricing snapshot up front — it is authoritative for discount,
// tax, and fees for both quote-based and direct jobs, because it is recalculated on
// every save and reflects any edits made after quote conversion.
QuotePricingBreakdownDto? jobBreakdown = null;
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
jobBreakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
// If the job came from a quote, load it so we can use the agreed pricing.
// The quote stores the approved total including oven batch cost and shop supplies —
// these are quote-level charges that are NOT stored on individual job items.
@@ -461,17 +469,15 @@ public class InvoicesController : Controller
});
}
// Use the quote's agreed tax rate and discount — not current company defaults
dto.TaxPercent = sourceQuote.TaxPercent;
// Use the quote's agreed tax rate and discount — these represent the customer-approved
// price and must not be recomputed from the job's current state.
dto.TaxPercent = sourceQuote.TaxPercent;
dto.DiscountAmount = sourceQuote.DiscountAmount;
}
else if (hadJobItems)
{
// Direct job — no source quote. Read all charges from the pricing snapshot so the
// invoice always matches the total shown on the job's Pricing Summary card.
QuotePricingBreakdownDto? jobBreakdown = null;
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
jobBreakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
if (job.OvenBatchCost > 0.01m)
{
@@ -529,6 +535,22 @@ public class InvoicesController : Controller
RevenueAccountId = defaultRevenueAccount?.Id
});
}
dto.DiscountAmount = jobBreakdown?.DiscountAmount ?? 0;
}
// Inherit payment terms from the source quote or the customer — more specific than
// the company-wide default set in the outer DTO. Quote terms take priority because
// they represent the agreed price; customer terms are next best for direct jobs.
var inheritedTerms = sourceQuote?.Terms ?? job.Customer?.PaymentTerms;
if (!string.IsNullOrWhiteSpace(inheritedTerms))
{
dto.Terms = inheritedTerms;
dto.DueDate = PaymentTermsParser.CalculateDueDate(inheritedTerms, DateTime.Today)
?? dto.DueDate;
var (discPct, discDays) = PaymentTermsParser.ParseEarlyPaymentDiscount(inheritedTerms);
dto.EarlyPaymentDiscountPercent = discPct;
dto.EarlyPaymentDiscountDays = discDays;
}
// Override tax to 0 for tax-exempt customers, regardless of company default or quote rate
@@ -2191,7 +2213,7 @@ public class InvoicesController : Controller
/// </summary>
private async Task PopulateCreateViewBagAsync(int companyId, string? selectedTerms = null)
{
var customers = await _unitOfWork.Customers.GetAllAsync();
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
ViewBag.Customers = customers.Where(c => c.IsActive).OrderBy(c => c.CompanyName ?? c.ContactLastName).ToList();
// Expose company default tax rate and exempt customer IDs for client-side tax handling
@@ -36,7 +36,9 @@ public class JobTemplatesController : Controller
/// </summary>
public async Task<IActionResult> Index()
{
var templates = await _unitOfWork.JobTemplates.GetAllAsync(
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var templates = await _unitOfWork.JobTemplates.FindAsync(
t => t.CompanyId == companyId,
false,
t => t.Customer,
t => t.Items);
@@ -498,6 +498,23 @@ public class JobsController : Controller
.OrderByDescending(t => t.TransactionDate).ToList();
ViewBag.MaterialsUsed = allJobTransactions;
// Inventory items for the manual log-material modal
var inventoryItemsForModal = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == job.CompanyId))
.OrderBy(i => i.Name)
.Select(i => new { i.Id, i.Name, i.Manufacturer, i.UnitOfMeasure, i.QuantityOnHand })
.ToList();
var jsonOpts = new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase };
ViewBag.InventoryItemsForModal = System.Text.Json.JsonSerializer.Serialize(inventoryItemsForModal, jsonOpts);
// IDs of powders already assigned to this job's coats — shown at top of log-material dropdown
var jobPowderIds = (jobDto.Items ?? new List<PowderCoating.Application.DTOs.Job.JobItemDto>())
.SelectMany(i => i.Coats ?? new List<PowderCoating.Application.DTOs.Job.JobItemCoatDto>())
.Where(c => c.InventoryItemId.HasValue)
.Select(c => c.InventoryItemId!.Value)
.Distinct()
.ToList();
ViewBag.JobPowderIds = System.Text.Json.JsonSerializer.Serialize(jobPowderIds, jsonOpts);
// Pre-logged powder grouped by InventoryItemId (for Complete Job modal pre-fill)
ViewBag.PreLoggedPowder = allJobTransactions
.GroupBy(t => t.InventoryItemId)
@@ -511,7 +528,7 @@ public class JobsController : Controller
ViewBag.JobPhotoMax = photoMax;
// Customer list for inline customer-change dropdown
var allCustomers = await _unitOfWork.Customers.GetAllAsync();
var allCustomers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == job.CompanyId);
ViewBag.CustomerSelectList = allCustomers
.Where(c => c.IsActive)
.Select(c => new SelectListItem
@@ -617,7 +634,8 @@ public class JobsController : Controller
if (job == null) return NotFound();
var allStatuses = (await _unitOfWork.JobStatusLookups.GetAllAsync())
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allStatuses = (await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == companyId))
.OrderBy(s => s.DisplayOrder).ToList();
ViewBag.AllStatuses = allStatuses;
@@ -640,7 +658,7 @@ public class JobsController : Controller
if (job == null) return NotFound();
var allStatuses = (await _unitOfWork.JobStatusLookups.GetAllAsync()).ToList();
var allStatuses = (await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == job.CompanyId)).ToList();
var newStatus = allStatuses.FirstOrDefault(s => s.Id == newStatusId);
if (newStatus == null) return BadRequest("Invalid status.");
@@ -828,7 +846,7 @@ public class JobsController : Controller
// Optionally advance status to In Preparation
if (advanceToInPreparation && jobToUpdate.JobStatus.StatusCode != AppConstants.StatusCodes.Job.InPreparation)
{
var allStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
var allStatuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == jobToUpdate.CompanyId);
var inPrepStatus = allStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.InPreparation);
if (inPrepStatus != null)
{
@@ -885,7 +903,7 @@ public class JobsController : Controller
if (advanceToInPreparation && job.JobStatus.StatusCode != AppConstants.StatusCodes.Job.InPreparation && !job.JobStatus.IsTerminalStatus)
{
var allStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
var allStatuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == job.CompanyId);
var inPrepStatus = allStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.InPreparation);
if (inPrepStatus != null)
{
@@ -1792,7 +1810,7 @@ public class JobsController : Controller
ViewBag.AiPhotoQuotesEnabled = await _subscriptionService.CanUseAiPhotoQuoteAsync(companyId);
await PopulateDropdowns();
await PopulatePrepServicesAsync();
await PopulatePrepServicesAsync(companyId);
var costs = await _pricingService.GetOperatingCostsAsync(companyId);
await PopulateJobItemDropDownsAsync(companyId, costs?.OvenOperatingCostPerHour ?? 45m);
ViewBag.TaxPercent = costs?.TaxPercent ?? 0m;
@@ -1812,7 +1830,9 @@ public class JobsController : Controller
/// </summary>
private async Task PopulateDropdowns()
{
var customers = await _unitOfWork.Customers.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
ViewBag.Customers = new SelectList(
customers.Where(c => c.IsActive).Select(c => new
{
@@ -1823,8 +1843,6 @@ public class JobsController : Controller
}).OrderBy(c => c.DisplayName),
"Id",
"DisplayName");
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var users = await _userManager.Users
.Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null)
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
@@ -2206,13 +2224,13 @@ public class JobsController : Controller
/// Loads all active prep services into ViewBag for the item wizard's prep services step.
/// Prep services are ordered by DisplayOrder so they appear in the intended workflow sequence.
/// </summary>
private async Task PopulatePrepServicesAsync()
private async Task PopulatePrepServicesAsync(int companyId)
{
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive);
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive && ps.CompanyId == companyId);
ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList();
_logger.LogInformation("Populated {Count} active prep services", prepServices.Count());
var blastSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive);
var blastSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive && b.CompanyId == companyId);
ViewBag.BlastSetups = blastSetups.OrderBy(b => b.DisplayOrder)
.Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault })
.ToList();
@@ -2648,78 +2666,80 @@ public class JobsController : Controller
.GroupBy(t => t.InventoryItemId)
.ToDictionary(g => g.Key, g => Math.Abs(g.Sum(t => t.Quantity)));
// Update actual powder usage for each coat
foreach (var coatUsage in dto.CoatUsages)
// Process powder usage submitted per inventory item (color) for the whole job.
// Distribute entered lbs across coats sharing that InventoryItemId proportionally
// by estimated PowderToOrder so per-coat reporting stays meaningful.
// One inventory deduction per powder (net of pre-logged credit).
if (dto.PowderUsages.Any())
{
var jobItemCoat = await _unitOfWork.JobItemCoats.GetByIdAsync(
coatUsage.JobItemCoatId,
false,
jic => jic.InventoryItem);
// Load all coats for the job with their inventory items
var allCoats = (await _unitOfWork.JobItemCoats.FindAsync(
jic => jic.JobItem != null && jic.JobItem.JobId == dto.JobId,
false, jic => jic.InventoryItem, jic => jic.JobItem))
.ToList();
if (jobItemCoat != null)
foreach (var powderUsage in dto.PowderUsages)
{
jobItemCoat.ActualPowderUsedLbs = coatUsage.ActualPowderUsedLbs;
await _unitOfWork.JobItemCoats.UpdateAsync(jobItemCoat);
if (!powderUsage.ActualPowderUsedLbs.HasValue || powderUsage.ActualPowderUsedLbs.Value <= 0)
continue;
_logger.LogInformation("Updated JobItemCoat {CoatId} with {Lbs} lbs actual powder used",
coatUsage.JobItemCoatId, coatUsage.ActualPowderUsedLbs);
var invItemId = powderUsage.InventoryItemId;
var totalActualLbs = powderUsage.ActualPowderUsedLbs.Value;
// Deduct powder from inventory if using stock powder
if (jobItemCoat.InventoryItemId.HasValue &&
coatUsage.ActualPowderUsedLbs.HasValue &&
coatUsage.ActualPowderUsedLbs.Value > 0)
// Distribute across coats using this powder proportionally by estimated lbs
var coatsForPowder = allCoats.Where(c => c.InventoryItemId == invItemId).ToList();
if (coatsForPowder.Any())
{
var invItemId = jobItemCoat.InventoryItemId.Value;
var actualLbs = coatUsage.ActualPowderUsedLbs.Value;
// Apply available pre-logged credit so we don't double-deduct
var credit = preLoggedCredit.GetValueOrDefault(invItemId, 0m);
var deductNow = Math.Max(0m, actualLbs - credit);
// Consume credit (other coats sharing the same powder get whatever remains)
preLoggedCredit[invItemId] = Math.Max(0m, credit - actualLbs);
if (deductNow > 0)
var totalEstimated = coatsForPowder.Sum(c => c.PowderToOrder ?? 0m);
foreach (var coat in coatsForPowder)
{
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(invItemId);
if (inventoryItem != null)
{
var transaction = new InventoryTransaction
{
InventoryItemId = inventoryItem.Id,
TransactionType = InventoryTransactionType.JobUsage,
Quantity = -deductNow,
UnitCost = inventoryItem.UnitCost,
TotalCost = inventoryItem.UnitCost * deductNow,
TransactionDate = DateTime.UtcNow,
JobId = job.Id,
Reference = job.JobNumber,
Notes = $"Powder used for Job {job.JobNumber} - {jobItemCoat.CoatName} ({jobItemCoat.ColorName ?? "N/A"}) by {currentUser!.FirstName} {currentUser.LastName}",
BalanceAfter = inventoryItem.QuantityOnHand - deductNow,
CompanyId = job.CompanyId
};
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
inventoryItem.QuantityOnHand -= deductNow;
await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem);
// GL: DR COGS, CR Inventory Asset (accrual) — no-op if accounts not configured
if (inventoryItem.CogsAccountId.HasValue && inventoryItem.InventoryAccountId.HasValue)
{
var cost = deductNow * (inventoryItem.AverageCost > 0 ? inventoryItem.AverageCost : inventoryItem.UnitCost);
await _accountBalanceService.DebitAsync(inventoryItem.CogsAccountId, cost);
await _accountBalanceService.CreditAsync(inventoryItem.InventoryAccountId, cost);
}
_logger.LogInformation(
"Deducted {Lbs} lbs (net of pre-logged) of {Item} from inventory for Job {JobNumber}. New quantity: {NewQty}",
deductNow, inventoryItem.Name, job.JobNumber, inventoryItem.QuantityOnHand);
}
var share = totalEstimated > 0
? totalActualLbs * ((coat.PowderToOrder ?? 0m) / totalEstimated)
: totalActualLbs / coatsForPowder.Count;
coat.ActualPowderUsedLbs = Math.Round(share, 4);
await _unitOfWork.JobItemCoats.UpdateAsync(coat);
}
else
}
// Single inventory deduction for the whole powder, net of pre-logged credit
var credit = preLoggedCredit.GetValueOrDefault(invItemId, 0m);
var deductNow = Math.Max(0m, totalActualLbs - credit);
preLoggedCredit[invItemId] = 0m;
if (deductNow > 0)
{
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(invItemId);
if (inventoryItem != null)
{
inventoryItem.QuantityOnHand -= deductNow;
await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem);
var transaction = new InventoryTransaction
{
InventoryItemId = inventoryItem.Id,
TransactionType = InventoryTransactionType.JobUsage,
Quantity = -deductNow,
UnitCost = inventoryItem.UnitCost,
TotalCost = inventoryItem.UnitCost * deductNow,
TransactionDate = DateTime.UtcNow,
JobId = job.Id,
Reference = job.JobNumber,
Notes = $"Powder used for Job {job.JobNumber} by {currentUser!.FirstName} {currentUser.LastName}",
BalanceAfter = inventoryItem.QuantityOnHand,
CompanyId = job.CompanyId
};
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
if (inventoryItem.CogsAccountId.HasValue && inventoryItem.InventoryAccountId.HasValue)
{
var cost = deductNow * (inventoryItem.AverageCost > 0 ? inventoryItem.AverageCost : inventoryItem.UnitCost);
await _accountBalanceService.DebitAsync(inventoryItem.CogsAccountId, cost);
await _accountBalanceService.CreditAsync(inventoryItem.InventoryAccountId, cost);
}
_logger.LogInformation(
"Skipped inventory deduction for JobItemCoat {CoatId} — {Lbs} lbs already pre-logged for inventory item {InvItemId}",
coatUsage.JobItemCoatId, actualLbs, invItemId);
"Deducted {Lbs} lbs (net of pre-logged) of {Item} from inventory for Job {JobNumber}. New quantity: {NewQty}",
deductNow, inventoryItem.Name, job.JobNumber, inventoryItem.QuantityOnHand);
}
}
}
@@ -3147,7 +3167,7 @@ public class JobsController : Controller
/// </summary>
private async Task PopulateJobItemDropDownsAsync(int companyId, decimal fallbackOvenRate)
{
var inventory = await _unitOfWork.InventoryItems.GetAllAsync(false, i => i.InventoryCategory);
var inventory = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId, false, i => i.InventoryCategory);
ViewBag.InventoryCoatings = inventory
.Where(i => i.IsActive && i.InventoryCategory?.IsActive == true && i.InventoryCategory.IsCoating)
.OrderBy(i => i.IsIncoming ? 1 : 0).ThenBy(i => i.InventoryCategory!.DisplayOrder).ThenBy(i => i.ColorName ?? i.Name)
@@ -3167,12 +3187,12 @@ public class JobsController : Controller
isIncoming = i.IsIncoming
}).ToList();
var vendors = await _unitOfWork.Vendors.GetAllAsync(false);
var vendors = await _unitOfWork.Vendors.FindAsync(s => s.CompanyId == companyId, false);
ViewBag.Vendors = vendors
.Where(s => s.IsActive).OrderBy(s => s.CompanyName)
.Select(s => new { value = s.Id.ToString(), text = s.CompanyName }).ToList();
var catalogItems = await _unitOfWork.CatalogItems.GetAllAsync(false, i => i.Category, i => i.Category.ParentCategory);
var catalogItems = await _unitOfWork.CatalogItems.FindAsync(i => i.CompanyId == companyId, false, i => i.Category, i => i.Category.ParentCategory);
ViewBag.CatalogItems = catalogItems
.Where(i => i.IsActive)
.OrderBy(i => i.Category.DisplayOrder).ThenBy(i => i.DisplayOrder)
@@ -3201,10 +3221,10 @@ public class JobsController : Controller
description = i.Description
}).ToList();
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive);
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive && ps.CompanyId == companyId);
ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList();
var blastSetupsForEditItems = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive);
var blastSetupsForEditItems = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive && b.CompanyId == companyId);
ViewBag.BlastSetups = blastSetupsForEditItems.OrderBy(b => b.DisplayOrder)
.Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault })
.ToList();
@@ -4080,9 +4100,87 @@ public class JobsController : Controller
_logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId);
}
/// <summary>
/// Logs manual material usage from the job details page. Mirrors the QR scan LogUsage
/// flow in InventoryController but returns JSON so the modal can close and refresh inline.
/// Quantity is always the amount USED (caller converts from remaining if needed).
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> LogMaterial([FromBody] LogMaterialRequest req)
{
try
{
if (req.QuantityUsed <= 0)
return Json(new { success = false, message = "Quantity used must be greater than zero." });
var item = await _unitOfWork.InventoryItems.GetByIdAsync(req.InventoryItemId);
if (item == null) return Json(new { success = false, message = "Inventory item not found." });
var job = await _unitOfWork.Jobs.GetByIdAsync(req.JobId);
if (job == null) return Json(new { success = false, message = "Job not found." });
var txnType = req.TransactionType == "Waste"
? InventoryTransactionType.Waste
: InventoryTransactionType.JobUsage;
item.QuantityOnHand -= req.QuantityUsed;
item.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.InventoryItems.UpdateAsync(item);
var txn = new PowderCoating.Core.Entities.InventoryTransaction
{
InventoryItemId = item.Id,
TransactionType = txnType,
Quantity = -req.QuantityUsed,
UnitCost = item.UnitCost,
TotalCost = req.QuantityUsed * item.UnitCost,
TransactionDate = DateTime.UtcNow,
BalanceAfter = item.QuantityOnHand,
JobId = req.JobId,
Reference = $"Job {job.JobNumber}",
Notes = req.Notes?.Trim(),
CompanyId = item.CompanyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.InventoryTransactions.AddAsync(txn);
await _unitOfWork.CompleteAsync();
// GL: DR COGS, CR Inventory Asset
if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue)
{
var cost = req.QuantityUsed * (item.AverageCost > 0 ? item.AverageCost : item.UnitCost);
await _accountBalanceService.DebitAsync(item.CogsAccountId, cost);
await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
}
return Json(new
{
success = true,
message = $"Logged {req.QuantityUsed:N2} {item.UnitOfMeasure} of {item.Name}.",
newBalance = item.QuantityOnHand,
unitOfMeasure = item.UnitOfMeasure,
itemName = item.Name
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error logging material for job {JobId}", req.JobId);
return Json(new { success = false, message = "An error occurred. Please try again." });
}
}
}
public class DeleteTimeEntryRequest { public int Id { get; set; } }
public class LogMaterialRequest
{
public int JobId { get; set; }
public int InventoryItemId { get; set; }
public decimal QuantityUsed { get; set; }
public string TransactionType { get; set; } = "JobUsage";
public string? Notes { get; set; }
}
public class CreateReworkJobRequest { public int ReworkRecordId { get; set; } public string? Notes { get; set; } }
public class UpdateWorkerAssignmentRequest
@@ -90,8 +90,8 @@ public class JobsPriorityController : Controller
.ToList();
// Get priorities and workers for modal options
var priorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId);
var workers = await _userManager.Users
.Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null)
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
@@ -16,15 +16,18 @@ public class MaintenanceController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly ITenantContext _tenantContext;
private readonly ILogger<MaintenanceController> _logger;
public MaintenanceController(
IUnitOfWork unitOfWork,
IMapper mapper,
ITenantContext tenantContext,
ILogger<MaintenanceController> logger)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_tenantContext = tenantContext;
_logger = logger;
}
@@ -740,7 +743,8 @@ public class MaintenanceController : Controller
/// </summary>
private async Task PopulateViewBagAsync(int? selectedEquipmentId = null)
{
var equipment = await _unitOfWork.Equipment.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var equipment = await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId);
ViewBag.EquipmentList = new SelectList(
equipment.Where(e => e.IsActive).OrderBy(e => e.EquipmentName),
"Id",
@@ -179,8 +179,9 @@ public class OvenSchedulerController : Controller
public async Task<IActionResult> Suggest([FromBody] SuggestRequest req)
{
var goal = req?.OptimizationGoal ?? "maximize_throughput";
var suggestCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var equipmentList = (await _unitOfWork.OvenCosts.GetAllAsync())
var equipmentList = (await _unitOfWork.OvenCosts.FindAsync(o => o.CompanyId == suggestCompanyId))
.Where(o => o.IsActive)
.OrderBy(o => o.DisplayOrder).ThenBy(o => o.Label)
.ToList();
@@ -188,10 +189,11 @@ public class OvenSchedulerController : Controller
if (!equipmentList.Any())
return Json(new { success = false, error = "No active ovens found. Add Named Ovens in Settings → Operating Costs." });
var companyCosts = await _unitOfWork.CompanyOperatingCosts.GetAllAsync();
var companyCosts = await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == suggestCompanyId);
var defaultCycleMinutes = companyCosts.FirstOrDefault()?.DefaultOvenCycleMinutes ?? 45;
var queueJobs = (await _unitOfWork.Jobs.GetAllAsync(
var queueJobs = (await _unitOfWork.Jobs.FindAsync(
j => j.CompanyId == suggestCompanyId,
false,
j => j.Customer,
j => j.JobStatus,
@@ -265,7 +267,8 @@ public class OvenSchedulerController : Controller
if (req?.Batches == null || !req.Batches.Any())
return Json(new { success = false, error = "No batches provided." });
var companyCosts = await _unitOfWork.CompanyOperatingCosts.GetAllAsync();
var acceptCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var companyCosts = await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == acceptCompanyId);
var defaultCycleMinutes = companyCosts.FirstOrDefault()?.DefaultOvenCycleMinutes ?? 45;
var createdBatches = new List<object>();
@@ -357,7 +360,8 @@ public class OvenSchedulerController : Controller
if (oven == null)
return Json(new { success = false, error = "Oven not found." });
var companyCosts = await _unitOfWork.CompanyOperatingCosts.GetAllAsync();
var createBatchCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var companyCosts = await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == createBatchCompanyId);
var defaultCycleMinutes = companyCosts.FirstOrDefault()?.DefaultOvenCycleMinutes ?? 45;
var batchNumber = await GenerateBatchNumberAsync();
@@ -651,7 +655,8 @@ public class OvenSchedulerController : Controller
if (inOvenStatus != null)
{
var jobIds = batch.Items.Select(i => i.JobId).Distinct().ToHashSet();
var jobs = (await _unitOfWork.Jobs.GetAllAsync()).Where(j => jobIds.Contains(j.Id));
var startBatchCid = _tenantContext.GetCurrentCompanyId() ?? 0;
var jobs = await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == startBatchCid && jobIds.Contains(j.Id));
foreach (var job in jobs)
job.JobStatusId = inOvenStatus.Id;
}
@@ -14,12 +14,14 @@ public class PricingTiersController : Controller
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly ILogger<PricingTiersController> _logger;
private readonly ITenantContext _tenantContext;
public PricingTiersController(IUnitOfWork unitOfWork, IMapper mapper, ILogger<PricingTiersController> logger)
public PricingTiersController(IUnitOfWork unitOfWork, IMapper mapper, ILogger<PricingTiersController> logger, ITenantContext tenantContext)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_logger = logger;
_tenantContext = tenantContext;
}
/// <summary>
@@ -27,8 +29,9 @@ public class PricingTiersController : Controller
/// </summary>
public async Task<IActionResult> Index()
{
var tiers = await _unitOfWork.PricingTiers.GetAllAsync();
var customers = await _unitOfWork.Customers.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var tiers = await _unitOfWork.PricingTiers.FindAsync(t => t.CompanyId == companyId);
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
var customerCountByTier = customers
.Where(c => c.PricingTierId.HasValue)
@@ -255,7 +255,7 @@ public class QuotesController : Controller
// Calibration nudge — suppress when named blast setups exist OR legacy CFM is set
var costs = (await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == companyId)).FirstOrDefault();
var hasNamedSetups = (await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive)).Any();
var hasNamedSetups = (await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive && b.CompanyId == companyId)).Any();
ViewBag.QuotingNotCalibrated = costs != null
&& !hasNamedSetups
&& costs.CompressorCfm == 0
@@ -441,7 +441,7 @@ public class QuotesController : Controller
ViewBag.Deposits = quoteDeposits;
// Customer list for inline customer-change dropdown
var allCustomers = await _unitOfWork.Customers.GetAllAsync();
var allCustomers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == quote.CompanyId);
ViewBag.CustomerSelectList = allCustomers
.Where(c => c.IsActive)
.Select(c => new SelectListItem
@@ -2430,7 +2430,7 @@ public class QuotesController : Controller
ViewBag.QuotePhotosEnabled = quotePhotoMax != 0; // 0 = feature disabled for this plan
// Customers
var customers = await _unitOfWork.Customers.GetAllAsync();
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
ViewBag.Customers = customers
.Select(c => new SelectListItem
{
@@ -2471,7 +2471,7 @@ public class QuotesController : Controller
}
// Inventory coatings — include incoming items so they can be quoted while powder is in transit
var inventory = await _unitOfWork.InventoryItems.GetAllAsync(false, i => i.InventoryCategory);
var inventory = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId, false, i => i.InventoryCategory);
ViewBag.InventoryCoatings = inventory
.Where(i => i.IsActive && i.InventoryCategory?.IsActive == true && i.InventoryCategory.IsCoating)
.OrderBy(i => i.IsIncoming ? 1 : 0).ThenBy(i => i.InventoryCategory!.DisplayOrder).ThenBy(i => i.ColorName ?? i.Name)
@@ -2492,13 +2492,13 @@ public class QuotesController : Controller
}).ToList();
// Vendors
var vendors = await _unitOfWork.Vendors.GetAllAsync(false);
var vendors = await _unitOfWork.Vendors.FindAsync(s => s.CompanyId == companyId, false);
ViewBag.Vendors = vendors
.Where(s => s.IsActive).OrderBy(s => s.CompanyName)
.Select(s => new { value = s.Id.ToString(), text = s.CompanyName }).ToList();
// Catalog items
var catalogItems = await _unitOfWork.CatalogItems.GetAllAsync(false, i => i.Category, i => i.Category.ParentCategory);
var catalogItems = await _unitOfWork.CatalogItems.FindAsync(i => i.CompanyId == companyId, false, i => i.Category, i => i.Category.ParentCategory);
ViewBag.CatalogItems = catalogItems
.Where(i => i.IsActive)
.OrderBy(i => i.Category.DisplayOrder).ThenBy(i => i.DisplayOrder)
@@ -2528,11 +2528,11 @@ public class QuotesController : Controller
}).ToList();
// Prep services
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive);
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive && ps.CompanyId == companyId);
ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList();
// Blast setups for wizard dropdown
var blastSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive);
var blastSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive && b.CompanyId == companyId);
ViewBag.BlastSetups = blastSetups.OrderBy(b => b.DisplayOrder)
.Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault })
.ToList();
@@ -2599,7 +2599,8 @@ public class QuotesController : Controller
/// </summary>
private async Task PopulatePricingTiersDropDownAsync()
{
var pricingTiers = await _unitOfWork.PricingTiers.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var pricingTiers = await _unitOfWork.PricingTiers.FindAsync(pt => pt.CompanyId == companyId);
ViewBag.PricingTiers = pricingTiers.OrderBy(pt => pt.TierName)
.Select(pt => new SelectListItem
{
@@ -2825,9 +2826,9 @@ public class QuotesController : Controller
// Do NOT assign fullItems to quote.QuoteItems — quote is a tracked entity and assigning
// no-tracking children (which may share InventoryItem instances) causes EF identity conflicts.
// Get default job statuses and priorities
var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
// Get default job statuses and priorities — scope to quote's company for defense-in-depth
var jobStatuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == quote.CompanyId);
var jobPriorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == quote.CompanyId);
var approvedStatus = jobStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Approved);
var normalPriority = jobPriorities.FirstOrDefault(p => p.PriorityCode == "NORMAL");
var rushPriority = jobPriorities.FirstOrDefault(p => p.PriorityCode == "RUSH");
@@ -3347,7 +3348,7 @@ public class QuotesController : Controller
CompanyBlastSetup? selectedBlastSetup = null;
if (request.BlastSetupId.HasValue)
{
var setups = await _unitOfWork.BlastSetups.FindAsync(b => b.Id == request.BlastSetupId.Value && b.IsActive);
var setups = await _unitOfWork.BlastSetups.FindAsync(b => b.Id == request.BlastSetupId.Value && b.IsActive && b.CompanyId == companyId);
selectedBlastSetup = setups.FirstOrDefault();
}
@@ -44,7 +44,8 @@ public class RecurringTemplatesController : Controller
/// <summary>Lists all recurring templates for the current company, active first then by name.</summary>
public async Task<IActionResult> Index()
{
var templates = await _unitOfWork.RecurringTemplates.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var templates = await _unitOfWork.RecurringTemplates.FindAsync(t => t.CompanyId == companyId);
return View(templates.OrderByDescending(t => t.IsActive).ThenBy(t => t.Name).ToList());
}
@@ -425,11 +426,12 @@ public class RecurringTemplatesController : Controller
/// <summary>Loads dropdowns for vendors, accounts, and payment methods into ViewBag.</summary>
private async Task PopulateDropDownsAsync()
{
var vendors = await _unitOfWork.Vendors.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId);
ViewBag.Vendors = vendors.OrderBy(v => v.CompanyName)
.Select(v => new SelectListItem(v.CompanyName, v.Id.ToString())).ToList();
var accounts = await _unitOfWork.Accounts.GetAllAsync();
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId);
ViewBag.APAccounts = accounts
.Where(a => a.AccountSubType == AccountSubType.AccountsPayable)
.OrderBy(a => a.AccountNumber)
@@ -11,6 +11,7 @@ using PowderCoating.Core.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Shared.Constants;
using PowderCoating.Web.ViewModels.Reports;
using System.Security.Claims;
namespace PowderCoating.Web.Controllers;
@@ -25,8 +26,9 @@ public class ReportsController : Controller
private readonly UserManager<ApplicationUser> _userManager;
private readonly IAccountingAiService _accountingAi;
private readonly IAiUsageLogger _usageLogger;
private readonly ITenantContext _tenantContext;
public ReportsController(IUnitOfWork unitOfWork, ILogger<ReportsController> logger, IFinancialReportService financialReports, IOperationalReportService operationalReports, IPdfService pdfService, UserManager<ApplicationUser> userManager, IAccountingAiService accountingAi, IAiUsageLogger usageLogger)
public ReportsController(IUnitOfWork unitOfWork, ILogger<ReportsController> logger, IFinancialReportService financialReports, IOperationalReportService operationalReports, IPdfService pdfService, UserManager<ApplicationUser> userManager, IAccountingAiService accountingAi, IAiUsageLogger usageLogger, ITenantContext tenantContext)
{
_unitOfWork = unitOfWork;
_logger = logger;
@@ -36,6 +38,7 @@ public class ReportsController : Controller
_userManager = userManager;
_accountingAi = accountingAi;
_usageLogger = usageLogger;
_tenantContext = tenantContext;
}
/// <summary>
@@ -79,27 +82,26 @@ public class ReportsController : Controller
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
"MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" };
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Load only necessary data - optimized with filtering and minimal eager loading
// Jobs: Load all jobs (we need various status filters and the collection is needed for job status distribution)
// Note: Date filtering would exclude data needed for jobsByStatus calculation
var jobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority, j => j.AssignedUser)).ToList();
// Load only necessary data — all explicitly scoped to this company
var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority, j => j.AssignedUser)).ToList();
// Quotes: Load all quotes (needed for quote status distribution and conversion funnel)
var quotes = (await _unitOfWork.Quotes.GetAllAsync(false, q => q.Customer, q => q.QuoteStatus)).ToList();
var quotes = (await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId, false, q => q.Customer, q => q.QuoteStatus)).ToList();
// Customers: Load all (needed for active count and customer creation trend across all months)
var customers = (await _unitOfWork.Customers.GetAllAsync()).ToList();
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)).ToList();
// Equipment: Load all for status distribution
var equipment = (await _unitOfWork.Equipment.GetAllAsync()).ToList();
var equipment = (await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId)).ToList();
// Inventory: Load all for low stock analysis
var inventory = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList();
var inventory = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
// Appointments: Filter to relevant date range at DB level
var appointments = (await _unitOfWork.Appointments.FindAsync(
a => a.ScheduledStartTime >= startDate,
a => a.CompanyId == companyId && a.ScheduledStartTime >= startDate,
false,
a => a.Customer,
a => a.AppointmentType,
@@ -108,7 +110,7 @@ public class ReportsController : Controller
// Users with assigned jobs/appointments will be loaded below when building worker stats
// CatalogItems: Load all for category distribution
var catalogItems = (await _unitOfWork.CatalogItems.GetAllAsync(false, c => c.Category)).ToList();
var catalogItems = (await _unitOfWork.CatalogItems.FindAsync(ci => ci.CompanyId == companyId, false, c => c.Category)).ToList();
// === OVERVIEW METRICS ===
var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
@@ -382,7 +384,7 @@ public class ReportsController : Controller
.ToDictionary(g => g.Key, g => g.Count());
// === FINANCIAL ANALYTICS ===
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList();
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
var totalInvoiced = activeInvoices.Sum(i => i.Total);
@@ -781,7 +783,7 @@ public class ReportsController : Controller
// === POWDER CONSUMPTION VS PURCHASE ===
var allInventoryTransactions = (await _unitOfWork.InventoryTransactions
.GetAllAsync(false, t => t.InventoryItem))
.FindAsync(t => t.CompanyId == companyId, false, t => t.InventoryItem))
.ToList();
var powderConsumptionItems = allInventoryTransactions
@@ -1309,14 +1311,15 @@ public class ReportsController : Controller
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
"MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" };
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var jobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).ToList();
var customers = (await _unitOfWork.Customers.GetAllAsync()).ToList();
var quotes = (await _unitOfWork.Quotes.GetAllAsync(false, q => q.QuoteStatus)).ToList();
var equipment = (await _unitOfWork.Equipment.GetAllAsync()).ToList();
var inventory = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList();
var allAppointments = await _unitOfWork.Appointments.GetAllAsync(false, a => a.AppointmentStatus);
var appointments = allAppointments.Where(a => a.ScheduledStartTime >= startDate).ToList();
var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).ToList();
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)).ToList();
var quotes = (await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId, false, q => q.QuoteStatus)).ToList();
var equipment = (await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId)).ToList();
var inventory = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
var allAppointments = await _unitOfWork.Appointments.FindAsync(a => a.CompanyId == companyId && a.ScheduledStartTime >= startDate, false, a => a.AppointmentStatus);
var appointments = allAppointments.ToList();
var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
var activeJobs = jobs.Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
@@ -1384,7 +1387,8 @@ public class ReportsController : Controller
var now = DateTime.UtcNow;
var startDate = now.AddMonths(-months);
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
var jobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).ToList();
var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
var inRange = completedJobs.Where(j => j.UpdatedAt >= startDate).ToList();
var byMonth = inRange.GroupBy(j => new DateTime(j.UpdatedAt!.Value.Year, j.UpdatedAt.Value.Month, 1)).ToDictionary(g => g.Key, g => g.ToList());
@@ -1430,12 +1434,13 @@ public class ReportsController : Controller
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
"MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" };
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var jobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.JobStatus, j => j.JobPriority, j => j.AssignedUser)).ToList();
var equipment = (await _unitOfWork.Equipment.GetAllAsync()).ToList();
var inventory = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList();
var allAppts = await _unitOfWork.Appointments.GetAllAsync(false, a => a.AppointmentType, a => a.AppointmentStatus);
var appointments = allAppts.Where(a => a.ScheduledStartTime >= startDate).ToList();
var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.JobStatus, j => j.JobPriority, j => j.AssignedUser)).ToList();
var equipment = (await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId)).ToList();
var inventory = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
var allAppts = await _unitOfWork.Appointments.FindAsync(a => a.CompanyId == companyId && a.ScheduledStartTime >= startDate, false, a => a.AppointmentType, a => a.AppointmentStatus);
var appointments = allAppts.ToList();
var activeJobs = jobs.Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
@@ -1483,10 +1488,11 @@ public class ReportsController : Controller
var now = DateTime.UtcNow;
var startDate = now.AddMonths(-months);
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
var customers = (await _unitOfWork.Customers.GetAllAsync()).ToList();
var quotes = (await _unitOfWork.Quotes.GetAllAsync(false, q => q.QuoteStatus)).ToList();
var catalogItems = (await _unitOfWork.CatalogItems.GetAllAsync(false, c => c.Category)).ToList();
var completedJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority))
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)).ToList();
var quotes = (await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId, false, q => q.QuoteStatus)).ToList();
var catalogItems = (await _unitOfWork.CatalogItems.FindAsync(ci => ci.CompanyId == companyId, false, c => c.Category)).ToList();
var completedJobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority))
.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
var customersByMonth = customers.Where(c => c.CreatedAt >= startDate).GroupBy(c => new DateTime(c.CreatedAt.Year, c.CreatedAt.Month, 1)).ToDictionary(g => g.Key, g => g.Count());
@@ -1523,7 +1529,8 @@ public class ReportsController : Controller
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
var now = DateTime.UtcNow;
var today = DateTime.Today;
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
var outstandingInvoices = activeInvoices.Where(i => i.Status != InvoiceStatus.Paid && i.Total > i.AmountPaid).ToList();
var overdueInvoices = activeInvoices.Where(i => i.Status != InvoiceStatus.Paid && i.DueDate.HasValue && i.DueDate.Value < today).ToList();
@@ -1574,7 +1581,8 @@ public class ReportsController : Controller
var monthLabels = new List<string>(); var monthlyBillsPaid = new List<decimal>(); var monthlyDirectExpenses = new List<decimal>();
// Also load collected payments for P&L comparison
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Payments)).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Payments)).ToList();
var paymentsByMonth = allInvoices.SelectMany(i => i.Payments.Where(p => !p.IsDeleted)).GroupBy(p => new DateTime(p.PaymentDate.Year, p.PaymentDate.Month, 1)).ToDictionary(g => g.Key, g => g.ToList());
var plRevenue = new List<decimal>(); var plExpenses = new List<decimal>(); var plNet = new List<decimal>();
for (var i = months - 1; i >= 0; i--)
@@ -1609,8 +1617,10 @@ public class ReportsController : Controller
{
var now = DateTime.UtcNow;
var startDate = now.AddMonths(-months);
var powderTransactions = (await _unitOfWork.InventoryTransactions.GetAllAsync(false, t => t.InventoryItem))
.Where(t => t.TransactionType == InventoryTransactionType.JobUsage && t.TransactionDate >= startDate).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var powderTransactions = (await _unitOfWork.InventoryTransactions.FindAsync(
t => t.CompanyId == companyId && t.TransactionType == InventoryTransactionType.JobUsage && t.TransactionDate >= startDate, false, t => t.InventoryItem))
.ToList();
var topColors = powderTransactions.Where(t => t.InventoryItem != null).GroupBy(t => t.InventoryItemId)
.Select(g => new PowderUsageByColorItem { InventoryItemId = g.Key, ColorName = g.First().InventoryItem!.ColorName ?? g.First().InventoryItem.Name, ColorCode = g.First().InventoryItem!.ColorCode, SKU = g.First().InventoryItem!.SKU, Manufacturer = g.First().InventoryItem!.Manufacturer, TotalLbsUsed = g.Sum(t => Math.Abs(t.Quantity)), TotalCost = g.Sum(t => Math.Abs(t.TotalCost)), JobCount = g.Where(t => !string.IsNullOrEmpty(t.Reference)).Select(t => t.Reference).Distinct().Count() })
@@ -1631,7 +1641,8 @@ public class ReportsController : Controller
/// <summary>Sales by Customer report — all active (non-voided) invoices grouped by customer, sorted by total invoiced.</summary>
public async Task<IActionResult> SalesByCustomer(int months = 6)
{
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
var items = activeInvoices.Where(i => i.Customer != null)
.GroupBy(i => new { i.CustomerId, Name = i.Customer!.IsCommercial ? i.Customer.CompanyName : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim(), i.Customer.IsCommercial })
@@ -1650,8 +1661,9 @@ public class ReportsController : Controller
{
var now = DateTime.UtcNow;
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
var customers = (await _unitOfWork.Customers.GetAllAsync()).ToList();
var completedJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.JobStatus)).Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)).ToList();
var completedJobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.JobStatus)).Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
var items = customers.Where(c => c.IsActive).Select(c =>
{
var cJobs = completedJobs.Where(j => j.CustomerId == c.Id).ToList();
@@ -1682,7 +1694,8 @@ public class ReportsController : Controller
{
var now = DateTime.UtcNow;
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
var completedJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.JobStatus)).Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode) && j.CompletedDate.HasValue).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var completedJobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.JobStatus)).Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode) && j.CompletedDate.HasValue).ToList();
var allStatusHistory = await _operationalReports.GetAllJobStatusHistoryAsync();
var historyByJob = allStatusHistory.GroupBy(h => h.JobId).ToDictionary(g => g.Key, g => g.OrderBy(h => h.ChangedDate).ToList());
var statusDisplayOrder = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK" };
@@ -1720,7 +1733,8 @@ public class ReportsController : Controller
var now = DateTime.UtcNow;
var today = DateTime.Today;
var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" };
var activeJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var activeJobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
var items = activeJobs.Select(j => new JobStatusAgingItem
{
JobId = j.Id, JobNumber = j.JobNumber, CustomerName = j.Customer?.IsCommercial == true ? j.Customer.CompanyName ?? "Unknown" : $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim(),
@@ -1740,7 +1754,8 @@ public class ReportsController : Controller
{
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
var today = DateTime.Today;
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
var items = allInvoices.Where(i => i.Customer != null && i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff && i.Status != InvoiceStatus.Paid)
.Select(i =>
{
@@ -1758,7 +1773,8 @@ public class ReportsController : Controller
/// </summary>
public async Task<IActionResult> PowderConsumption(int months = 6)
{
var allTx = (await _unitOfWork.InventoryTransactions.GetAllAsync(false, t => t.InventoryItem)).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allTx = (await _unitOfWork.InventoryTransactions.FindAsync(t => t.CompanyId == companyId, false, t => t.InventoryItem)).ToList();
var items = allTx.Where(t => t.InventoryItem != null)
.GroupBy(t => new { t.InventoryItemId, t.InventoryItem!.Name, t.InventoryItem.SKU, t.InventoryItem.ColorName, t.InventoryItem.ColorCode, t.InventoryItem.Manufacturer })
.Select(g => new PowderConsumptionItem { InventoryItemId = g.Key.InventoryItemId, ItemName = g.Key.Name, SKU = g.Key.SKU, ColorName = g.Key.ColorName, ColorCode = g.Key.ColorCode, Manufacturer = g.Key.Manufacturer, TotalPurchasedLbs = g.Where(t => t.TransactionType == InventoryTransactionType.Purchase || t.TransactionType == InventoryTransactionType.Initial).Sum(t => t.Quantity), TotalConsumedLbs = g.Where(t => t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Waste).Sum(t => Math.Abs(t.Quantity)), PurchaseCount = g.Count(t => t.TransactionType == InventoryTransactionType.Purchase), UsageJobCount = g.Where(t => t.TransactionType == InventoryTransactionType.JobUsage && !string.IsNullOrEmpty(t.Reference)).Select(t => t.Reference).Distinct().Count() })
@@ -1776,8 +1792,9 @@ public class ReportsController : Controller
public async Task<IActionResult> InventoryTurnover(int months = 6)
{
var daysInPeriod = months * 30.0;
var inventory = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList();
var allTx = (await _unitOfWork.InventoryTransactions.GetAllAsync(false, t => t.InventoryItem)).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var inventory = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
var allTx = (await _unitOfWork.InventoryTransactions.FindAsync(t => t.CompanyId == companyId, false, t => t.InventoryItem)).ToList();
var items = inventory.Where(i => i.IsActive).Select(i =>
{
var iTx = allTx.Where(t => t.InventoryItemId == i.Id).ToList();
@@ -1835,8 +1852,9 @@ public class ReportsController : Controller
var now = DateTime.UtcNow;
var today = DateTime.Today;
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Load invoices for AR data
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList();
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
var outstandingInvoices = activeInvoices.Where(i => i.BalanceDue > 0 && i.Status != InvoiceStatus.Paid).ToList();
@@ -1930,13 +1948,14 @@ public class ReportsController : Controller
var companyName = await GetCompanyNameAsync();
var today = DateTime.Today;
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Open AR invoices
var openInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments))
var openInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments))
.Where(i => i.BalanceDue > 0 && i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff && i.Status != InvoiceStatus.Paid)
.ToList();
// Compute avg days to pay per customer from paid invoices
var paidInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Payments))
var paidInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Payments))
.Where(i => i.Status == InvoiceStatus.Paid && i.InvoiceDate != default)
.ToList();
var avgDaysByCustomer = paidInvoices
@@ -2137,7 +2156,8 @@ public class ReportsController : Controller
var companyName = await GetCompanyNameAsync();
var today = DateTime.Today;
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
var activeInvoices = allInvoices.Where(i =>
i.Status != InvoiceStatus.Voided &&
i.Status != InvoiceStatus.WrittenOff).ToList();
@@ -2256,8 +2276,9 @@ public class ReportsController : Controller
var companyName = await GetCompanyNameAsync();
var now = DateTime.UtcNow;
var startOfYear = new DateTime(now.Year, 1, 1);
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments))
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments))
.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff)
.ToList();
@@ -15,11 +15,13 @@ namespace PowderCoating.Web.Controllers;
public class SmsConsentAuditController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ITenantContext _tenantContext;
private readonly ILogger<SmsConsentAuditController> _logger;
public SmsConsentAuditController(IUnitOfWork unitOfWork, ILogger<SmsConsentAuditController> logger)
public SmsConsentAuditController(IUnitOfWork unitOfWork, ITenantContext tenantContext, ILogger<SmsConsentAuditController> logger)
{
_unitOfWork = unitOfWork;
_tenantContext = tenantContext;
_logger = logger;
}
@@ -30,7 +32,8 @@ public class SmsConsentAuditController : Controller
{
try
{
var allCustomers = await _unitOfWork.Customers.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allCustomers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
if (!string.IsNullOrWhiteSpace(search))
{
@@ -98,7 +101,8 @@ public class SmsConsentAuditController : Controller
{
try
{
var customers = (await _unitOfWork.Customers.GetAllAsync())
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId))
.OrderBy(c => c.CompanyName ?? c.ContactLastName ?? c.ContactFirstName)
.ToList();
@@ -32,7 +32,8 @@ public class TaxRatesController : Controller
[HttpGet]
public async Task<IActionResult> Index()
{
var rates = await _unitOfWork.TaxRates.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var rates = await _unitOfWork.TaxRates.FindAsync(r => r.CompanyId == companyId);
return View(rates.OrderBy(r => r.Name).ToList());
}
@@ -87,7 +87,8 @@ public class ToolsController : Controller
[HttpGet]
public async Task<IActionResult> GetImportAccounts()
{
var allAccounts = await _unitOfWork.Accounts.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId);
var revenue = allAccounts
.Where(a => a.AccountType == AccountType.Revenue && a.IsActive)
@@ -123,7 +124,8 @@ public class ToolsController : Controller
/// </summary>
private async Task PopulateImportAccountDropdownsAsync()
{
var allAccounts = await _unitOfWork.Accounts.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId);
var revenueAccounts = allAccounts
.Where(a => a.AccountType == AccountType.Revenue && a.IsActive)
@@ -1102,7 +1104,7 @@ public class ToolsController : Controller
// Validate account IDs belong to this company — stale page load can produce IDs
// that were valid before a data reset but no longer exist.
var validAccountIds = (await _unitOfWork.Accounts.GetAllAsync())
var validAccountIds = (await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value))
.Select(a => a.Id).ToHashSet();
if (revenueAccountId.HasValue && !validAccountIds.Contains(revenueAccountId.Value))
revenueAccountId = null;
@@ -1167,7 +1169,7 @@ public class ToolsController : Controller
// Validate account IDs belong to this company — stale page load can produce IDs
// that were valid before a data reset but no longer exist.
var validAccountIds = (await _unitOfWork.Accounts.GetAllAsync())
var validAccountIds = (await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value))
.Select(a => a.Id).ToHashSet();
if (inventoryAccountId.HasValue && !validAccountIds.Contains(inventoryAccountId.Value))
inventoryAccountId = null;
@@ -1939,7 +1941,7 @@ public class ToolsController : Controller
using (var archive = new System.IO.Compression.ZipArchive(memoryStream, System.IO.Compression.ZipArchiveMode.Create, true))
{
// 1. Customers
var customers = await _unitOfWork.Customers.GetAllAsync();
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId.Value);
var customersCsv = GenerateCustomersCsv(customers);
var customersEntry = archive.CreateEntry($"customers_{timestamp}.csv");
using (var entryStream = customersEntry.Open())
@@ -1949,7 +1951,7 @@ public class ToolsController : Controller
}
// 2. Quotes
var quotes = await _unitOfWork.Quotes.GetAllAsync(false, q => q.Customer, q => q.QuoteStatus);
var quotes = await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId.Value, false, q => q.Customer, q => q.QuoteStatus);
var quotesCsv = GenerateQuotesCsv(quotes);
var quotesEntry = archive.CreateEntry($"quotes_{timestamp}.csv");
using (var entryStream = quotesEntry.Open())
@@ -1959,7 +1961,7 @@ public class ToolsController : Controller
}
// 3. Jobs
var jobs = await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority);
var jobs = await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId.Value, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority);
var jobsCsv = GenerateJobsCsv(jobs);
var jobsEntry = archive.CreateEntry($"jobs_{timestamp}.csv");
using (var entryStream = jobsEntry.Open())
@@ -1969,7 +1971,7 @@ public class ToolsController : Controller
}
// 4. Appointments
var appointments = await _unitOfWork.Appointments.GetAllAsync(false,
var appointments = await _unitOfWork.Appointments.FindAsync(a => a.CompanyId == companyId.Value, false,
a => a.Customer, a => a.AppointmentType, a => a.AppointmentStatus);
var appointmentsCsv = GenerateAppointmentsCsv(appointments);
var appointmentsEntry = archive.CreateEntry($"appointments_{timestamp}.csv");
@@ -1980,9 +1982,9 @@ public class ToolsController : Controller
}
// 5. Catalog
var catalogCategories = await _unitOfWork.CatalogCategories.GetAllAsync();
var catalogCategories = await _unitOfWork.CatalogCategories.FindAsync(cc => cc.CompanyId == companyId.Value);
var catalogCategoryPaths = BuildCategoryPathMap(catalogCategories);
var catalog = await _unitOfWork.CatalogItems.GetAllAsync();
var catalog = await _unitOfWork.CatalogItems.FindAsync(ci => ci.CompanyId == companyId.Value);
var catalogCsv = GenerateCatalogCsv(catalog, catalogCategoryPaths);
var catalogEntry = archive.CreateEntry($"catalog_{timestamp}.csv");
using (var entryStream = catalogEntry.Open())
@@ -1992,7 +1994,7 @@ public class ToolsController : Controller
}
// 6. Inventory
var inventory = await _unitOfWork.InventoryItems.GetAllAsync();
var inventory = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId.Value);
var inventoryCsv = GenerateInventoryCsv(inventory);
var inventoryEntry = archive.CreateEntry($"inventory_{timestamp}.csv");
using (var entryStream = inventoryEntry.Open())
@@ -2002,7 +2004,7 @@ public class ToolsController : Controller
}
// 7. Equipment
var equipment = await _unitOfWork.Equipment.GetAllAsync();
var equipment = await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId.Value);
var equipmentCsv = GenerateEquipmentCsv(equipment);
var equipmentEntry = archive.CreateEntry($"equipment_{timestamp}.csv");
using (var entryStream = equipmentEntry.Open())
@@ -2012,7 +2014,7 @@ public class ToolsController : Controller
}
// 8. Maintenance
var maintenance = await _unitOfWork.MaintenanceRecords.GetAllAsync(false, m => m.Equipment);
var maintenance = await _unitOfWork.MaintenanceRecords.FindAsync(m => m.CompanyId == companyId.Value, false, m => m.Equipment);
var maintenanceCsv = GenerateMaintenanceCsv(maintenance);
var maintenanceEntry = archive.CreateEntry($"maintenance_{timestamp}.csv");
using (var entryStream = maintenanceEntry.Open())
@@ -2022,7 +2024,7 @@ public class ToolsController : Controller
}
// 9. Vendors
var vendors = await _unitOfWork.Vendors.GetAllAsync();
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId.Value);
var vendorsCsv = GenerateVendorsCsv(vendors);
var vendorsEntry = archive.CreateEntry($"vendors_{timestamp}.csv");
using (var entryStream = vendorsEntry.Open())
@@ -2032,7 +2034,7 @@ public class ToolsController : Controller
}
// 10. Prep Services
var prepServices = await _unitOfWork.PrepServices.GetAllAsync();
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.CompanyId == companyId.Value);
var prepServicesCsv = GeneratePrepServicesCsv(prepServices);
var prepServicesEntry = archive.CreateEntry($"prep_services_{timestamp}.csv");
using (var entryStream = prepServicesEntry.Open())
@@ -2042,7 +2044,7 @@ public class ToolsController : Controller
}
// 11. Invoices
var invoices = await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Job);
var invoices = await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId.Value, false, i => i.Customer, i => i.Job);
var invoicesCsv = GenerateInvoicesCsv(invoices);
var invoicesEntry = archive.CreateEntry($"invoices_{timestamp}.csv");
using (var entryStream = invoicesEntry.Open())
@@ -2052,7 +2054,7 @@ public class ToolsController : Controller
}
// 12. Chart of Accounts
var accounts = await _unitOfWork.Accounts.GetAllAsync();
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value);
var accountsCsv = GenerateChartOfAccountsCsv(accounts);
var accountsEntry = archive.CreateEntry($"chart_of_accounts_{timestamp}.csv");
using (var entryStream = accountsEntry.Open())
@@ -2062,7 +2064,7 @@ public class ToolsController : Controller
}
// 13. Expenses
var expenses = await _unitOfWork.Expenses.GetAllAsync(false, e => e.ExpenseAccount, e => e.PaymentAccount, e => e.Vendor, e => e.Job);
var expenses = await _unitOfWork.Expenses.FindAsync(e => e.CompanyId == companyId.Value, false, e => e.ExpenseAccount, e => e.PaymentAccount, e => e.Vendor, e => e.Job);
var expensesCsv = GenerateExpensesCsv(expenses);
var expensesEntry = archive.CreateEntry($"expenses_{timestamp}.csv");
using (var entryStream = expensesEntry.Open())
@@ -2072,7 +2074,7 @@ public class ToolsController : Controller
}
// 14. Payments
var payments = await _unitOfWork.Payments.GetAllAsync(false, p => p.Invoice);
var payments = await _unitOfWork.Payments.FindAsync(p => p.CompanyId == companyId.Value, false, p => p.Invoice);
var paymentsCsv = GeneratePaymentsCsv(payments);
var paymentsEntry = archive.CreateEntry($"payments_{timestamp}.csv");
using (var entryStream = paymentsEntry.Open())
@@ -2258,9 +2260,9 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index));
}
var catalogCategories = await _unitOfWork.CatalogCategories.GetAllAsync();
var catalogCategories = await _unitOfWork.CatalogCategories.FindAsync(cc => cc.CompanyId == companyId.Value);
var catalogCategoryPaths = BuildCategoryPathMap(catalogCategories);
var catalogItems = await _unitOfWork.CatalogItems.GetAllAsync();
var catalogItems = await _unitOfWork.CatalogItems.FindAsync(ci => ci.CompanyId == companyId.Value);
var csv = GenerateCatalogCsv(catalogItems, catalogCategoryPaths);
var fileName = $"catalog_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -2326,7 +2328,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index));
}
var equipment = await _unitOfWork.Equipment.GetAllAsync();
var equipment = await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId.Value);
var csv = GenerateEquipmentCsv(equipment);
var fileName = $"equipment_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -2407,13 +2409,13 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index));
}
// Load all lookup tables
var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
var quoteStatuses = await _unitOfWork.QuoteStatusLookups.GetAllAsync();
var inventoryCategories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
var appointmentStatuses = await _unitOfWork.AppointmentStatusLookups.GetAllAsync();
var appointmentTypes = await _unitOfWork.AppointmentTypeLookups.GetAllAsync();
// Load all lookup tables — scoped to this company
var jobStatuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == companyId.Value);
var jobPriorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId.Value);
var quoteStatuses = await _unitOfWork.QuoteStatusLookups.FindAsync(s => s.CompanyId == companyId.Value);
var inventoryCategories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId.Value);
var appointmentStatuses = await _unitOfWork.AppointmentStatusLookups.FindAsync(s => s.CompanyId == companyId.Value);
var appointmentTypes = await _unitOfWork.AppointmentTypeLookups.FindAsync(t => t.CompanyId == companyId.Value);
var csv = GenerateCompanySettingsCsv(company, jobStatuses, jobPriorities,
quoteStatuses, inventoryCategories, appointmentStatuses, appointmentTypes);
@@ -4092,7 +4094,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index));
}
var prepServices = await _unitOfWork.PrepServices.GetAllAsync();
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.CompanyId == companyId.Value);
var csv = GeneratePrepServicesCsv(prepServices);
var fileName = $"prep_services_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -4124,7 +4126,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index));
}
var vendors = await _unitOfWork.Vendors.GetAllAsync();
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId.Value);
var csv = GenerateVendorsCsv(vendors);
var fileName = $"vendors_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -4156,7 +4158,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index));
}
var accounts = await _unitOfWork.Accounts.GetAllAsync();
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value);
var csv = GenerateChartOfAccountsCsv(accounts);
var fileName = $"chart_of_accounts_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -302,7 +302,7 @@ public static class HelpKnowledgeBase
**Changing the customer on a job:** On the Job Details page, the Customer field is an always-visible dropdown. Select a different customer a confirmation banner appears. Click **Save** to apply or **Cancel** to revert. Use this to correct a misassigned job or to move a walk-in job to a customer's proper record after they've been added to the system.
**Creating an invoice from a job:** On the Job Details page, look for the Invoice section and click "Create Invoice."
**Creating an invoice from a job:** On the Job Details page, look for the Invoice section and click "Create Invoice." The system pre-fills all line items, pricing, discount, tax rate, payment terms, and due date from the job and customer automatically. Review the Totals panel on the right if a discount was applied to the job it will show as a red "Discount Applied" line. Adjust anything you need, then save.
**Work Order QR Codes:** Every printed job work order includes two tiers of QR codes one for viewing the job, and a separate set for taking action on it. All QR codes require the worker to be logged in.
@@ -314,6 +314,8 @@ public static class HelpKnowledgeBase
All QR codes require login workers must have an active account. Logging in once on their phone is sufficient for the session.
**Logging material usage from a PC (without QR scan):** On the Job Details page, expand the Materials Used section and click **Log Material**. A modal opens where you can: select any inventory item from a dropdown (current stock level shown), choose whether to enter the amount used or the amount remaining (the system calculates usage automatically), pick a reason (Job Usage or Waste/Spillage), and add optional notes. Saves immediately and updates inventory on hand.
**Blank Work Order:** Print a pre-formatted paper work order to hand to a walk-in customer before creating a digital job record.
- Access: Jobs list page printer icon button "Blank Work Order" in the top-right toolbar. Or navigate directly to /WorkOrder/Blank.
- The PDF opens in a new tab ready to print. It includes: company logo and address, Drop Off Date field, Client Name / Client Phone / Due Date fields, 12-row parts table (Part Description / Color / Quote), Notes box, customizable Terms & Conditions text, and a Customer Signature line.
@@ -48,8 +48,9 @@
<ol class="mb-3">
<li class="mb-2">Open the job from <strong>Operations &rsaquo; Jobs</strong> and go to its Details page.</li>
<li class="mb-2">Scroll to the <strong>Invoice</strong> section near the bottom of the page.</li>
<li class="mb-2">Click <strong>Create Invoice</strong>. The system generates an invoice pre-filled with all the job's line items and the final pricing.</li>
<li class="mb-2">Review the invoice — check line items, totals, and the due date — then click <strong>Save Invoice</strong>.</li>
<li class="mb-2">Click <strong>Create Invoice</strong>. The system pre-fills all line items, the discount, tax rate, payment terms, and due date from the job and customer automatically.</li>
<li class="mb-2">Review the <strong>Totals</strong> panel on the right &mdash; if a discount was applied to the job it shows as a red <em>Discount Applied</em> line below the subtotal. Negative line items are allowed if you need to apply a manual credit or price adjustment.</li>
<li class="mb-2">Adjust anything you need, then click <strong>Save Invoice</strong>.</li>
</ol>
<h3 class="h6 fw-semibold mt-3 mb-2">From the Invoices list (manual)</h3>
@@ -139,7 +140,7 @@
<li class="mb-2">Open the invoice from <strong>Operations &rsaquo; Invoices</strong> or from the job's Details page.</li>
<li class="mb-2">Click <strong>Send Invoice</strong>. The status changes from Draft to Sent.</li>
<li class="mb-2">If email notifications are configured, the customer receives an email with the invoice details and total due.</li>
<li class="mb-2">A due date is set automatically based on the customer's payment terms (e.g., Net 30 means the due date is 30 days from today).</li>
<li class="mb-2">The due date and payment terms are pre-filled from the source quote (if the job came from a quote) or the customer&rsquo;s payment terms &mdash; you can always override them before saving.</li>
</ol>
<p>
You can also click <strong>Download PDF</strong> on any invoice to generate a print-ready PDF
+16 -1
View File
@@ -607,13 +607,28 @@
no anonymous bumps.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2"><i class="bi bi-box-seam me-1"></i>Bottom QR Log Powder Usage</h3>
<h3 class="h6 fw-semibold mt-3 mb-2"><i class="bi bi-box-seam me-1"></i>Bottom QR &mdash; Log Powder Usage</h3>
<p>
One QR per unique powder on the job. Scanning opens the inventory usage log page pre-filled
with that powder and the job number, so you can record actual lbs used in seconds without
navigating through the app.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2"><i class="bi bi-droplet-half me-1"></i>Logging Material Usage from a PC</h3>
<p>
You don&rsquo;t need a phone or QR code to log material usage. On the Job Details page, expand the
<strong>Materials Used</strong> section and click <strong>Log Material</strong>. A modal opens where you can:
</p>
<ul class="mb-2">
<li>Select any inventory item from a searchable dropdown &mdash; the item&rsquo;s current stock level is shown when you pick it.</li>
<li>Choose <strong>Amount Used</strong> (enter how much was consumed) or <strong>Amount Remaining</strong> (enter what&rsquo;s left in the bag &mdash; the system calculates the usage automatically).</li>
<li>Pick a reason: <em>Job Usage</em> or <em>Waste / Spillage</em>.</li>
<li>Add optional notes.</li>
</ul>
<p>
Saving immediately reduces the item&rsquo;s stock on hand and creates an entry in the Inventory Activity ledger, exactly like a QR scan would. The QR scan icon is still available next to the button for mobile workers.
</p>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-lock flex-shrink-0 mt-1"></i>
<div>
@@ -283,13 +283,13 @@
<td class="text-end">
<input type="number" name="InvoiceItems[@i].UnitPrice"
class="form-control form-control-sm text-end unit-price-input"
value="@item.UnitPrice.ToString("F2")" min="0" step="0.01"
value="@item.UnitPrice.ToString("F2")" step="0.01"
onchange="recalcRow(this)" oninput="recalcRow(this)" />
</td>
<td class="text-end">
<input type="number" name="InvoiceItems[@i].TotalPrice"
class="form-control form-control-sm text-end total-price-input"
value="@item.TotalPrice.ToString("F2")" min="0" step="0.01"
value="@item.TotalPrice.ToString("F2")" step="0.01"
oninput="recalcTotals()" />
</td>
<td class="text-center">
@@ -371,6 +371,10 @@
<input asp-for="DiscountAmount" type="number" class="form-control form-control-sm text-end"
min="0" step="0.01" oninput="recalcTotals()" />
</div>
<div id="discountRow" class="d-flex justify-content-between mb-1 d-none">
<span class="text-muted small">Discount Applied</span>
<span id="displayDiscount" class="small text-danger">&minus;$0.00</span>
</div>
<div class="mb-2">
<div class="d-flex justify-content-between align-items-center mb-1">
<label class="form-label mb-0 text-muted">Tax (%)</label>
@@ -725,13 +729,13 @@
<td class="text-end">
<input type="number" name="InvoiceItems[${idx}].UnitPrice"
class="form-control form-control-sm text-end unit-price-input"
value="${unitPrice.toFixed(2)}" min="0" step="0.01"
value="${unitPrice.toFixed(2)}" step="0.01"
onchange="recalcRow(this)" oninput="recalcRow(this)" />
</td>
<td class="text-end">
<input type="number" name="InvoiceItems[${idx}].TotalPrice"
class="form-control form-control-sm text-end total-price-input"
value="${total}" min="0" step="0.01"
value="${total}" step="0.01"
oninput="recalcTotals()" />
</td>
<td class="text-center">
@@ -797,6 +801,15 @@
const total = taxableAmount + tax;
document.getElementById('displaySubtotal').textContent = formatCurrency(subtotal);
const discountRow = document.getElementById('discountRow');
if (discountRow) {
if (discount > 0) {
document.getElementById('displayDiscount').textContent = '' + formatCurrency(discount);
discountRow.classList.remove('d-none');
} else {
discountRow.classList.add('d-none');
}
}
document.getElementById('displayTax').textContent = formatCurrency(tax);
document.getElementById('displayTotal').textContent = formatCurrency(total);
}
@@ -1016,9 +1016,12 @@
<span class="badge bg-primary rounded-pill ms-1">@materialsUsed.Count</span>
}
<i class="bi bi-chevron-down collapse-chevron ms-1" style="transition:transform .2s;"></i>
<span class="ms-auto">
<a asp-controller="Inventory" asp-action="Scan" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation();">
<i class="bi bi-qr-code-scan me-1"></i>Log Material
<span class="ms-auto d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation(); openLogMaterialModal();">
<i class="bi bi-plus-circle me-1"></i>Log Material
</button>
<a asp-controller="Inventory" asp-action="Scan" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation();" title="Scan QR code">
<i class="bi bi-qr-code-scan"></i>
</a>
</span>
</div>
@@ -1028,7 +1031,7 @@
{
<div class="card-body text-muted text-center py-3 small">
<i class="bi bi-droplet me-1"></i>No materials have been logged for this job yet.
Use the QR label on an inventory item to log usage.
Click <strong>Log Material</strong> above or scan the QR label on an inventory item.
</div>
}
else
@@ -1089,6 +1092,78 @@
</div><!-- /collapseMaterials -->
</div>
<!-- Log Material Modal -->
<div class="modal fade" id="logMaterialModal" tabindex="-1" aria-labelledby="logMaterialModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="logMaterialModalLabel">
<i class="bi bi-droplet-half me-2 text-primary"></i>Log Material Usage
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label fw-semibold">Inventory Item <span class="text-danger">*</span></label>
<div class="position-relative">
<div class="input-group">
<input type="text" class="form-control" id="lmItemSearch"
placeholder="Search by name or manufacturer&hellip;" autocomplete="off"
oninput="lmComboInput()"
onfocus="lmComboOpen()"
onkeydown="lmComboKey(event)">
<button class="btn btn-outline-secondary" type="button" tabindex="-1"
id="lmItemDropdownToggle" onclick="lmComboToggle()">
<i class="bi bi-chevron-down" style="font-size:.75rem;"></i>
</button>
</div>
<div id="lmItemDropdown"
style="display:none;max-height:220px;overflow-y:auto;z-index:1070;background:#fff;border:1px solid rgba(0,0,0,.15);border-radius:.375rem;box-shadow:0 4px 12px rgba(0,0,0,.12);">
</div>
</div>
<div id="lmItemBalance" class="form-text text-muted d-none"></div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Entry Method</label>
<div class="d-flex gap-3">
<div class="form-check">
<input class="form-check-input" type="radio" name="lmEntryMethod" id="lmMethodUsed" value="used" checked onchange="lmUpdateQuantityLabel()">
<label class="form-check-label" for="lmMethodUsed">Amount Used</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="lmEntryMethod" id="lmMethodRemaining" value="remaining" onchange="lmUpdateQuantityLabel()">
<label class="form-check-label" for="lmMethodRemaining">Amount Remaining</label>
</div>
</div>
</div>
<div class="mb-3">
<label id="lmQtyLabel" class="form-label fw-semibold">Quantity Used <span class="text-danger">*</span></label>
<input type="number" id="lmQuantity" class="form-control" min="0" step="0.01" placeholder="0.00">
<div id="lmComputedUsed" class="form-text text-muted d-none"></div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Reason</label>
<select id="lmTransactionType" class="form-select">
<option value="JobUsage">Job Usage</option>
<option value="Waste">Waste / Spillage</option>
</select>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Notes</label>
<textarea id="lmNotes" class="form-control" rows="2" placeholder="Optional"></textarea>
</div>
<div id="lmAlert" class="alert alert-permanent d-none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="lmSaveBtn" onclick="lmSave()">
<i class="bi bi-check-circle me-1"></i>Log Usage
</button>
</div>
</div>
</div>
</div>
<!-- Part Intake Modal -->
@{
var intakeExpectedCount = Model.Items?.Sum(i => (int)i.Quantity) ?? 0;
@@ -3082,6 +3157,19 @@
}
}
<!-- Log Material Modal JS -->
<script src="/js/log-material.js"></script>
<script>
(function () {
const inventoryItems = @Html.Raw(ViewBag.InventoryItemsForModal ?? "[]");
const jobPowderIds = @Html.Raw(ViewBag.JobPowderIds ?? "[]");
const jobId = @Model.Id;
const logUrl = '@Url.Action("LogMaterial", "Jobs")';
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
window.__logMaterial = { inventoryItems, jobPowderIds, jobId, logUrl, token };
})();
</script>
<!-- Save as Template Modal -->
<div class="modal fade" id="saveTemplateModal" tabindex="-1" aria-labelledby="saveTemplateModalLabel" aria-hidden="true">
<div class="modal-dialog">
@@ -2,8 +2,21 @@
@{
var emailDefault = ViewBag.EmailDefaultOnComplete == true;
var preLoggedPowder = ViewBag.PreLoggedPowder as Dictionary<int, decimal> ?? new Dictionary<int, decimal>();
// Track remaining credit per InventoryItemId as we allocate it across coat rows
var remainingCredit = preLoggedPowder.ToDictionary(kv => kv.Key, kv => kv.Value);
// Group all coats by inventory item so we ask once per powder color, not once per item/coat
var powderGroups = (Model.Items ?? new List<PowderCoating.Application.DTOs.Job.JobItemDto>())
.SelectMany(i => i.Coats ?? new List<PowderCoating.Application.DTOs.Job.JobItemCoatDto>())
.Where(c => c.InventoryItemId.HasValue)
.GroupBy(c => c.InventoryItemId!.Value)
.Select(g => new {
InventoryItemId = g.Key,
ColorName = g.First().ColorName,
ColorCode = g.First().ColorCode,
TotalEstimatedLbs = g.Sum(c => c.PowderToOrder ?? 0m),
PreLogged = preLoggedPowder.GetValueOrDefault(g.Key, 0m)
})
.OrderBy(g => g.ColorName)
.ToList();
}
<div class="modal fade" id="completeJobModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-lg">
@@ -27,102 +40,59 @@
<div class="form-text">Enter the total time in hours (e.g., 2.5 for 2 hours 30 minutes)</div>
</div>
@if (Model.Items != null && Model.Items.Any())
@if (powderGroups.Any())
{
<div class="mb-3">
<h6 class="fw-semibold mb-3">
<h6 class="fw-semibold mb-1">
<i class="bi bi-palette me-1 text-primary"></i>Actual Powder Usage
</h6>
<p class="text-muted small mb-3">Enter total lbs used per powder color for the entire job.</p>
<div class="table-responsive">
<table class="table table-sm table-hover">
<thead class="table-light">
<tr>
<th>Item</th>
<th>Coat</th>
<th>Color</th>
<th>Color / Powder</th>
<th class="text-end">Estimated (lbs)</th>
<th>Actual (lbs)</th>
<th style="width:150px">Actual Used (lbs)</th>
</tr>
</thead>
<tbody>
@{
var coatIndex = 0;
}
@foreach (var item in Model.Items)
@for (int i = 0; i < powderGroups.Count; i++)
{
if (item.Coats != null && item.Coats.Any())
{
foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
{
<tr>
<td>
<small>@item.Description</small>
@if (item.Quantity > 1)
{
<span class="badge bg-secondary ms-1">&times;@item.Quantity</span>
}
</td>
<td><span class="badge bg-secondary">@coat.CoatName</span></td>
<td>
@if (!string.IsNullOrEmpty(coat.ColorName))
{
<small>
@coat.ColorName
@if (!string.IsNullOrEmpty(coat.ColorCode))
{
<span class="text-muted">(@coat.ColorCode)</span>
}
</small>
}
</td>
<td class="text-end">
<small class="text-muted">@((coat.PowderToOrder ?? 0).ToString("0.##"))</small>
</td>
<td>
@{
decimal preFilledLbs = 0m;
if (coat.InventoryItemId.HasValue && remainingCredit.TryGetValue(coat.InventoryItemId.Value, out var availCredit) && availCredit > 0)
{
preFilledLbs = availCredit;
remainingCredit[coat.InventoryItemId.Value] = 0m;
}
}
<input type="hidden" name="CoatUsages[@coatIndex].JobItemCoatId" value="@coat.Id" />
<input type="number"
class="form-control form-control-sm"
name="CoatUsages[@coatIndex].ActualPowderUsedLbs"
step="0.01" min="0" placeholder="0.00"
value="@(preFilledLbs > 0 ? preFilledLbs.ToString("0.##") : "")"
style="max-width: 120px;">
@if (preFilledLbs > 0)
{
<small class="text-success d-block mt-1">
<i class="bi bi-check-circle me-1"></i>Already logged — inventory adjusted
</small>
}
</td>
</tr>
coatIndex++;
}
}
else
{
<tr class="table-secondary">
<td colspan="5">
<small class="text-muted fst-italic">
<i class="bi bi-info-circle me-1"></i>
@item.Description — No coat information available (legacy job item)
var pg = powderGroups[i];
<tr>
<td>
<span class="fw-semibold">@pg.ColorName</span>
@if (!string.IsNullOrEmpty(pg.ColorCode))
{
<small class="text-muted ms-1">(@pg.ColorCode)</small>
}
</td>
<td class="text-end text-muted small align-middle">
@pg.TotalEstimatedLbs.ToString("0.##")
</td>
<td>
<input type="hidden" name="PowderUsages[@i].InventoryItemId" value="@pg.InventoryItemId" />
<input type="number"
class="form-control form-control-sm"
name="PowderUsages[@i].ActualPowderUsedLbs"
step="0.01" min="0" placeholder="0.00"
value="@(pg.PreLogged > 0 ? pg.PreLogged.ToString("0.##") : "")">
@if (pg.PreLogged > 0)
{
<small class="text-success d-block mt-1">
<i class="bi bi-check-circle me-1"></i>@pg.PreLogged.ToString("0.##") lbs already logged
</small>
</td>
</tr>
}
}
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="alert alert-info alert-permanent mb-0">
<i class="bi bi-info-circle me-2"></i>
<small>Pre-filled values were already logged via scan inventory is already adjusted for those. You can edit the amount; only the difference will be applied to inventory.</small>
<small>Pre-filled values were already logged via scan &mdash; inventory is already adjusted for those. You can edit the amount; only the difference will be applied.</small>
</div>
</div>
}
@@ -0,0 +1,285 @@
/**
* Log Material Usage modal job details page.
* Reads config from window.__logMaterial injected inline by the view.
*/
(function () {
let _items = [];
let _jobPowderIds = new Set();
let _modal = null;
// ── Combobox state ────────────────────────────────────────────────────────
let _selectedItemId = 0;
function lmComboInput() {
const q = document.getElementById('lmItemSearch')?.value?.toLowerCase() || '';
lmComboRender(q);
lmComboShow();
_selectedItemId = 0;
document.getElementById('lmItemBalance').classList.add('d-none');
lmOnQtyInput();
}
function lmComboOpen() {
const q = document.getElementById('lmItemSearch')?.value?.toLowerCase() || '';
lmComboRender(q);
lmComboShow();
}
function lmComboToggle() {
const dd = document.getElementById('lmItemDropdown');
if (!dd) return;
if (dd.style.display === 'none' || !dd.style.display) {
lmComboOpen();
document.getElementById('lmItemSearch')?.focus();
} else {
lmComboClose();
}
}
function lmMakeRow(it) {
const display = (it.manufacturer ? escLm(it.manufacturer) + ' &ndash; ' : '') +
escLm(it.name) +
(it.unitOfMeasure ? ' <span class="text-muted" style="font-size:.82rem;">(' + escLm(it.unitOfMeasure) + ')</span>' : '');
const label = (it.manufacturer ? it.manufacturer + ' - ' : '') +
it.name +
(it.unitOfMeasure ? ' (' + it.unitOfMeasure + ')' : '');
return `<div class="lm-item-opt" style="padding:.35rem .75rem;font-size:.875rem;cursor:pointer;"
data-id="${it.id}"
data-qty="${it.quantityOnHand}"
data-uom="${escLm(it.unitOfMeasure || '')}"
data-label="${escLm(label)}"
onmousedown="event.preventDefault(); lmComboSelect(this)"
onmouseenter="this.style.background='#f0f4ff'"
onmouseleave="this.classList.contains('lm-active') ? null : this.style.background=''">
${display}
</div>`;
}
function lmComboRender(query) {
const dd = document.getElementById('lmItemDropdown');
if (!dd) return;
const filtered = query
? _items.filter(it => it.name.toLowerCase().includes(query) ||
(it.manufacturer && it.manufacturer.toLowerCase().includes(query)) ||
(it.unitOfMeasure && it.unitOfMeasure.toLowerCase().includes(query)))
: _items;
if (filtered.length === 0) {
dd.innerHTML = '<div class="px-3 py-2 text-muted small">No items match.</div>';
return;
}
const jobItems = filtered.filter(it => _jobPowderIds.has(it.id));
const otherItems = filtered.filter(it => !_jobPowderIds.has(it.id));
let html = '';
if (jobItems.length > 0) {
html += '<div class="px-3 py-1 text-muted" style="font-size:.72rem;letter-spacing:.04em;text-transform:uppercase;background:#f8f9fa;border-bottom:1px solid #dee2e6;">This Job</div>';
html += jobItems.map(lmMakeRow).join('');
if (otherItems.length > 0) {
html += '<div style="height:1px;background:#dee2e6;margin:.25rem 0;"></div>';
html += '<div class="px-3 py-1 text-muted" style="font-size:.72rem;letter-spacing:.04em;text-transform:uppercase;background:#f8f9fa;border-bottom:1px solid #dee2e6;">All Inventory</div>';
}
}
html += otherItems.map(lmMakeRow).join('');
dd.innerHTML = html;
}
function lmComboShow() {
const dd = document.getElementById('lmItemDropdown');
const anchor = document.getElementById('lmItemSearch');
if (!dd || !anchor) return;
const rect = anchor.closest('.input-group').getBoundingClientRect();
dd.style.position = 'fixed';
dd.style.top = (rect.bottom + 2) + 'px';
dd.style.left = rect.left + 'px';
dd.style.width = rect.width + 'px';
dd.style.display = 'block';
}
function lmComboClose() {
const dd = document.getElementById('lmItemDropdown');
if (dd) dd.style.display = 'none';
}
window.lmComboSelect = function (el) {
_selectedItemId = parseInt(el.dataset.id) || 0;
document.getElementById('lmItemSearch').value = el.dataset.label;
lmComboClose();
const qty = parseFloat(el.dataset.qty) || 0;
const uom = el.dataset.uom;
const balDiv = document.getElementById('lmItemBalance');
balDiv.textContent = 'Current stock: ' + qty.toFixed(2) + (uom ? ' ' + uom : '');
balDiv.classList.remove('d-none');
lmOnQtyInput();
};
window.lmComboInput = lmComboInput;
window.lmComboOpen = lmComboOpen;
window.lmComboToggle = lmComboToggle;
window.lmComboKey = function (event) {
const dd = document.getElementById('lmItemDropdown');
if (!dd || dd.style.display === 'none') {
if (event.key === 'ArrowDown' || event.key === 'Enter') {
event.preventDefault();
lmComboOpen();
}
return;
}
const opts = Array.from(dd.querySelectorAll('.lm-item-opt'));
let idx = opts.findIndex(o => o.classList.contains('lm-active'));
if (event.key === 'ArrowDown') {
event.preventDefault();
idx = Math.min(idx + 1, opts.length - 1);
opts.forEach(o => { o.classList.remove('lm-active'); o.style.background = ''; });
if (opts[idx]) { opts[idx].classList.add('lm-active'); opts[idx].style.background = '#e8eeff'; opts[idx].scrollIntoView({ block: 'nearest' }); }
} else if (event.key === 'ArrowUp') {
event.preventDefault();
idx = Math.max(idx - 1, 0);
opts.forEach(o => { o.classList.remove('lm-active'); o.style.background = ''; });
if (opts[idx]) { opts[idx].classList.add('lm-active'); opts[idx].style.background = '#e8eeff'; opts[idx].scrollIntoView({ block: 'nearest' }); }
} else if (event.key === 'Enter') {
event.preventDefault();
const active = dd.querySelector('.lm-active') || opts[0];
if (active) active.dispatchEvent(new MouseEvent('mousedown'));
} else if (event.key === 'Escape') {
lmComboClose();
}
};
function escLm(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── Quantity / label logic ────────────────────────────────────────────────
function lmOnQtyInput() {
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
if (method !== 'remaining') {
document.getElementById('lmComputedUsed').classList.add('d-none');
return;
}
if (!_selectedItemId) {
document.getElementById('lmComputedUsed').classList.add('d-none');
return;
}
const item = _items.find(it => it.id === _selectedItemId);
const onHand = item ? (parseFloat(item.quantityOnHand) || 0) : 0;
const remaining = parseFloat(document.getElementById('lmQuantity').value) || 0;
const used = onHand - remaining;
const computedDiv = document.getElementById('lmComputedUsed');
computedDiv.textContent = 'Usage = ' + onHand.toFixed(2) + ' ' + remaining.toFixed(2) + ' = ' + used.toFixed(2) + (item?.unitOfMeasure ? ' ' + item.unitOfMeasure : '');
computedDiv.classList.remove('d-none');
}
window.lmUpdateQuantityLabel = function () {
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
document.getElementById('lmQtyLabel').innerHTML =
(method === 'remaining' ? 'Quantity Remaining' : 'Quantity Used') +
' <span class="text-danger">*</span>';
lmOnQtyInput();
};
// ── Modal open / save ─────────────────────────────────────────────────────
window.openLogMaterialModal = function () {
_selectedItemId = 0;
document.getElementById('lmItemSearch').value = '';
document.getElementById('lmItemBalance').classList.add('d-none');
document.getElementById('lmQuantity').value = '';
document.getElementById('lmComputedUsed').classList.add('d-none');
document.getElementById('lmTransactionType').value = 'JobUsage';
document.getElementById('lmNotes').value = '';
document.getElementById('lmAlert').classList.add('d-none');
document.getElementById('lmSaveBtn').disabled = false;
document.getElementById('lmMethodUsed').checked = true;
window.lmUpdateQuantityLabel();
lmComboClose();
if (_modal) _modal.show();
};
window.lmSave = async function () {
const cfg = window.__logMaterial;
const alertEl = document.getElementById('lmAlert');
function showError(msg) {
alertEl.className = 'alert alert-danger alert-permanent';
alertEl.textContent = msg;
alertEl.classList.remove('d-none');
}
if (!_selectedItemId) { showError('Please select an inventory item.'); return; }
const qtyInput = parseFloat(document.getElementById('lmQuantity').value) || 0;
if (qtyInput <= 0) { showError('Please enter a quantity greater than zero.'); return; }
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
let quantityUsed = qtyInput;
if (method === 'remaining') {
const item = _items.find(it => it.id === _selectedItemId);
const onHand = item ? (parseFloat(item.quantityOnHand) || 0) : 0;
quantityUsed = onHand - qtyInput;
if (quantityUsed <= 0) {
showError('Remaining quantity cannot be equal to or greater than the current stock (' + onHand.toFixed(2) + ').');
return;
}
}
const btn = document.getElementById('lmSaveBtn');
btn.disabled = true;
alertEl.classList.add('d-none');
try {
const resp = await fetch(cfg.logUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': cfg.token
},
body: JSON.stringify({
jobId: cfg.jobId,
inventoryItemId: _selectedItemId,
quantityUsed: quantityUsed,
transactionType: document.getElementById('lmTransactionType').value,
notes: document.getElementById('lmNotes').value.trim() || null
})
});
const data = await resp.json();
if (data.success) {
if (_modal) _modal.hide();
window.location.reload();
} else {
showError(data.message || 'An error occurred.');
btn.disabled = false;
}
} catch {
showError('Network error. Please try again.');
btn.disabled = false;
}
};
// ── Init ──────────────────────────────────────────────────────────────────
function init() {
const cfg = window.__logMaterial;
if (!cfg) return;
_items = cfg.inventoryItems || [];
_jobPowderIds = new Set(cfg.jobPowderIds || []);
_modal = new bootstrap.Modal(document.getElementById('logMaterialModal'));
document.getElementById('lmQuantity').addEventListener('input', lmOnQtyInput);
// Close dropdown when clicking outside
document.addEventListener('click', function (e) {
if (!e.target.closest('#lmItemSearch') &&
!e.target.closest('#lmItemDropdown') &&
!e.target.closest('#lmItemDropdownToggle')) {
lmComboClose();
}
});
}
document.addEventListener('DOMContentLoaded', init);
})();
@@ -0,0 +1,284 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Infrastructure.Repositories;
using PowderCoating.Web.Controllers;
namespace PowderCoating.UnitTests;
/// <summary>
/// Verifies that the explicit <c>CompanyId == companyId</c> predicates added to every
/// user-facing controller action actually prevent cross-tenant data leakage.
///
/// Each test seeds entities for TWO companies, creates a controller whose ITenantContext
/// returns Company 1's ID, calls the action, and asserts that Company 2's data never
/// appears in the result.
///
/// These tests validate the defense-in-depth layer (explicit predicates in controllers)
/// independently of the EF Core global query filters, which behave differently on the
/// in-memory provider when no HttpContext is present.
/// </summary>
public class MultiTenantIsolationTests
{
// ── Repository-level isolation ────────────────────────────────────────────
/// <summary>
/// FindAsync with an explicit CompanyId predicate returns only the matching company's rows,
/// even when rows from other companies exist in the database.
/// </summary>
[Fact]
public async Task Repository_FindAsync_WithCompanyIdPredicate_ExcludesOtherTenants()
{
await using var context = CreateContext();
context.Customers.Add(MakeCustomer(id: 1, companyId: 1, firstName: "Alice"));
context.Customers.Add(MakeCustomer(id: 2, companyId: 2, firstName: "Bob"));
context.Customers.Add(MakeCustomer(id: 3, companyId: 1, firstName: "Carol"));
await context.SaveChangesAsync();
var uow = new UnitOfWork(context);
var results = (await uow.Customers.FindAsync(c => c.CompanyId == 1)).ToList();
Assert.Equal(2, results.Count);
Assert.All(results, c => Assert.Equal(1, c.CompanyId));
Assert.DoesNotContain(results, c => c.ContactFirstName == "Bob");
}
[Fact]
public async Task Repository_FindAsync_ReturnsEmpty_WhenNoMatchingCompanyId()
{
await using var context = CreateContext();
context.Customers.Add(MakeCustomer(id: 1, companyId: 2, firstName: "Bob"));
await context.SaveChangesAsync();
var uow = new UnitOfWork(context);
var results = await uow.Customers.FindAsync(c => c.CompanyId == 1);
Assert.Empty(results);
}
// ── SmsConsentAuditController ─────────────────────────────────────────────
[Fact]
public async Task SmsConsentAudit_Index_ReturnsOnlyCurrentCompanyCustomers()
{
await using var context = CreateContext();
context.Customers.Add(MakeCustomer(id: 1, companyId: 1, firstName: "Alice"));
context.Customers.Add(MakeCustomer(id: 2, companyId: 2, firstName: "Bob")); // other company
context.Customers.Add(MakeCustomer(id: 3, companyId: 1, firstName: "Carol"));
await context.SaveChangesAsync();
var controller = new SmsConsentAuditController(
new UnitOfWork(context),
MockTenant(companyId: 1),
Mock.Of<ILogger<SmsConsentAuditController>>());
SetHttpContext(controller);
var result = await controller.Index();
var view = Assert.IsType<ViewResult>(result);
var vm = Assert.IsType<SmsConsentAuditViewModel>(view.Model);
Assert.Equal(2, vm.TotalCount);
Assert.DoesNotContain(vm.Rows, r => r.CustomerName.Contains("Bob"));
}
[Fact]
public async Task SmsConsentAudit_ExportCsv_ContainsOnlyCurrentCompanyCustomers()
{
await using var context = CreateContext();
context.Customers.Add(MakeCustomer(id: 1, companyId: 1, firstName: "Alice"));
context.Customers.Add(MakeCustomer(id: 2, companyId: 2, firstName: "Bob"));
await context.SaveChangesAsync();
var controller = new SmsConsentAuditController(
new UnitOfWork(context),
MockTenant(companyId: 1),
Mock.Of<ILogger<SmsConsentAuditController>>());
SetHttpContext(controller);
var result = await controller.ExportCsv();
var file = Assert.IsType<FileContentResult>(result);
var csv = System.Text.Encoding.UTF8.GetString(file.FileContents);
Assert.Contains("Alice", csv);
Assert.DoesNotContain("Bob", csv);
}
// ── TaxRatesController ────────────────────────────────────────────────────
[Fact]
public async Task TaxRates_Index_ReturnsOnlyCurrentCompanyRates()
{
await using var context = CreateContext();
context.TaxRates.Add(MakeTaxRate(id: 1, companyId: 1, name: "State Tax"));
context.TaxRates.Add(MakeTaxRate(id: 2, companyId: 2, name: "Foreign Tax")); // other company
context.TaxRates.Add(MakeTaxRate(id: 3, companyId: 1, name: "Local Tax"));
await context.SaveChangesAsync();
var controller = new TaxRatesController(
new UnitOfWork(context),
MockTenant(companyId: 1),
Mock.Of<ILogger<TaxRatesController>>());
SetHttpContext(controller);
var result = await controller.Index();
var view = Assert.IsType<ViewResult>(result);
var rates = Assert.IsAssignableFrom<IEnumerable<TaxRate>>(view.Model).ToList();
Assert.Equal(2, rates.Count);
Assert.All(rates, r => Assert.Equal(1, r.CompanyId));
Assert.DoesNotContain(rates, r => r.Name == "Foreign Tax");
}
// ── RecurringTemplatesController ──────────────────────────────────────────
[Fact]
public async Task RecurringTemplates_Index_ReturnsOnlyCurrentCompanyTemplates()
{
await using var context = CreateContext();
context.RecurringTemplates.Add(MakeRecurringTemplate(id: 1, companyId: 1, name: "Monthly Rent"));
context.RecurringTemplates.Add(MakeRecurringTemplate(id: 2, companyId: 2, name: "Other Tenant Bill")); // other company
await context.SaveChangesAsync();
var controller = new RecurringTemplatesController(
new UnitOfWork(context),
MockTenant(companyId: 1),
CreateUserManagerMock().Object,
Mock.Of<ILogger<RecurringTemplatesController>>());
SetHttpContext(controller);
var result = await controller.Index();
var view = Assert.IsType<ViewResult>(result);
var templates = Assert.IsAssignableFrom<IEnumerable<RecurringTemplate>>(view.Model).ToList();
Assert.Single(templates);
Assert.Equal("Monthly Rent", templates[0].Name);
}
// ── JobTemplatesController ────────────────────────────────────────────────
[Fact]
public async Task JobTemplates_Index_ReturnsOnlyCurrentCompanyTemplates()
{
await using var context = CreateContext();
context.JobTemplates.Add(MakeJobTemplate(id: 1, companyId: 1, name: "Standard Wheel Coat"));
context.JobTemplates.Add(MakeJobTemplate(id: 2, companyId: 2, name: "Other Company Template")); // other company
await context.SaveChangesAsync();
var controller = new JobTemplatesController(
new UnitOfWork(context),
MockTenant(companyId: 1));
SetHttpContext(controller);
var result = await controller.Index();
var view = Assert.IsType<ViewResult>(result);
var templates = Assert.IsAssignableFrom<IEnumerable<JobTemplate>>(view.Model).ToList();
Assert.Single(templates);
Assert.Equal("Standard Wheel Coat", templates[0].Name);
}
// ── Cross-tenant write protection ─────────────────────────────────────────
/// <summary>
/// Verifies that the companyId-scoped FindAsync used for SMS export returns zero
/// rows for a company that has no customers, even when another company has many.
/// Guards against the "empty predicate returns all" regression.
/// </summary>
[Fact]
public async Task SmsConsentAudit_ExportCsv_IsEmpty_WhenCompanyHasNoCustomers()
{
await using var context = CreateContext();
context.Customers.Add(MakeCustomer(id: 1, companyId: 2, firstName: "Other"));
context.Customers.Add(MakeCustomer(id: 2, companyId: 2, firstName: "Also Other"));
await context.SaveChangesAsync();
var controller = new SmsConsentAuditController(
new UnitOfWork(context),
MockTenant(companyId: 1), // Company 1 has no customers
Mock.Of<ILogger<SmsConsentAuditController>>());
SetHttpContext(controller);
var result = await controller.ExportCsv();
var file = Assert.IsType<FileContentResult>(result);
var csv = System.Text.Encoding.UTF8.GetString(file.FileContents);
// Only header row, no data rows
Assert.DoesNotContain("Other", csv);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
/// <summary>Returns a mock ITenantContext that always yields the given companyId.</summary>
private static ITenantContext MockTenant(int companyId)
{
var mock = new Mock<ITenantContext>();
mock.Setup(t => t.GetCurrentCompanyId()).Returns(companyId);
return mock.Object;
}
private static void SetHttpContext(Controller controller)
{
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
};
}
private static Customer MakeCustomer(int id, int companyId, string firstName) => new()
{
Id = id,
CompanyId = companyId,
ContactFirstName = firstName,
ContactLastName = "Test",
IsCommercial = false
};
private static TaxRate MakeTaxRate(int id, int companyId, string name) => new()
{
Id = id,
CompanyId = companyId,
Name = name,
Rate = 8.5m
};
private static RecurringTemplate MakeRecurringTemplate(int id, int companyId, string name) => new()
{
Id = id,
CompanyId = companyId,
Name = name,
TemplateType = RecurringTemplateType.Bill,
Frequency = RecurringFrequency.Monthly,
IntervalCount = 1,
NextFireDate = DateTime.Today,
IsActive = true
};
private static JobTemplate MakeJobTemplate(int id, int companyId, string name) => new()
{
Id = id,
CompanyId = companyId,
Name = name
};
private static Mock<UserManager<ApplicationUser>> CreateUserManagerMock()
{
var store = new Mock<IUserStore<ApplicationUser>>();
return new Mock<UserManager<ApplicationUser>>(
store.Object, null!, null!, null!, null!, null!, null!, null!, null!);
}
}