Compare commits

...

8 Commits

Author SHA1 Message Date
spouliot 8a0a564885 Merge dev into master for release v2026.05.18 2026-05-18 19:08:11 -04:00
spouliot dd4785b048 Fix empty-state button/text on list pages when search returns no results
Show 'Add Your First X' and onboarding copy only when the list is truly
empty. When a search or filter is active with no results, show 'Add X'
and 'No X match your search/filters' instead.

Affected: Customers (table + mobile views), Equipment, Inventory.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 18:49:23 -04:00
jenkins e185e3b7e3 Add XML doc comments to pricing assembly services
Added comprehensive XML documentation to JobItemAssemblyService and
QuotePricingAssemblyService — the most complex area of the codebase.
Comments explain the three-overload pattern, seed class rationale,
powder-to-order formula and industry default fallbacks, AI prediction
override tracking, and the incoming inventory auto-creation workflow.
PricingCalculationService was already well-documented; no changes needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 14:22:32 -04:00
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
30 changed files with 964 additions and 259 deletions
@@ -4,8 +4,26 @@ using PowderCoating.Core.Entities;
namespace PowderCoating.Application.Services; namespace PowderCoating.Application.Services;
/// <summary>
/// Converts quote/job data into persisted <see cref="JobItem"/>, <see cref="JobItemCoat"/>,
/// and <see cref="JobItemPrepService"/> entities.
///
/// Three source types are supported, each with a matching overload:
/// 1. <see cref="CreateQuoteItemDto"/> — quote wizard (new job from form data + fresh pricing result)
/// 2. <see cref="QuoteItem"/> — quote-to-job conversion (copies a saved quote line)
/// 3. <see cref="JobItem"/> — job duplication / template instantiation (copies an existing job line)
///
/// The private <see cref="JobItemSeed"/> / <see cref="JobItemCoatSeed"/> / <see cref="JobItemPrepServiceSeed"/>
/// intermediary classes exist solely to give all three overload paths a single <see cref="BuildJobItem"/>
/// construction site — avoiding subtle copy-paste drift where one overload forgets to copy a new field.
/// </summary>
public class JobItemAssemblyService : IJobItemAssemblyService public class JobItemAssemblyService : IJobItemAssemblyService
{ {
/// <summary>
/// Creates a <see cref="JobItem"/> from a quote wizard DTO and a pre-calculated pricing result.
/// Used when creating a job directly from the job form or from an approved quote via the wizard.
/// Pricing is passed in separately because it was already computed upstream (CalculateQuoteItemPriceAsync).
/// </summary>
public JobItem CreateJobItem(CreateQuoteItemDto source, int jobId, int companyId, QuoteItemPricingResult pricing, DateTime createdAtUtc) public JobItem CreateJobItem(CreateQuoteItemDto source, int jobId, int companyId, QuoteItemPricingResult pricing, DateTime createdAtUtc)
{ {
ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(source);
@@ -42,6 +60,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
createdAtUtc); createdAtUtc);
} }
/// <summary>
/// Builds <see cref="JobItemCoat"/> records from the coat DTOs in the quote wizard form.
/// PowderToOrder is recalculated server-side here (not trusted from the form) using surface area,
/// quantity, coverage, and transfer efficiency — the wizard's displayed value is for UI only.
/// </summary>
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc) public IReadOnlyList<JobItemCoat> CreateJobItemCoats(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
{ {
ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(source);
@@ -70,6 +93,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
.ToList() ?? []; .ToList() ?? [];
} }
/// <summary>
/// Builds <see cref="JobItemPrepService"/> records (sandblasting, masking, etc.) from the
/// quote wizard DTO. These are per-item prep steps with individual time estimates that feed
/// labor cost calculations and shop floor instructions.
/// </summary>
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc) public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
{ {
ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(source);
@@ -85,6 +113,13 @@ public class JobItemAssemblyService : IJobItemAssemblyService
createdAtUtc); createdAtUtc);
} }
/// <summary>
/// Creates a <see cref="JobItem"/> by copying a saved <see cref="QuoteItem"/> during quote-to-job conversion.
/// Prices are taken directly from the quote snapshot — no repricing occurs — so the job starts with
/// exactly the amounts that were approved by the customer.
/// The first coat's color/finish is promoted to the job item's top-level fields for quick display
/// (details remain in the coat records).
/// </summary>
public JobItem CreateJobItem(QuoteItem source, int jobId, int companyId, DateTime createdAtUtc) public JobItem CreateJobItem(QuoteItem source, int jobId, int companyId, DateTime createdAtUtc)
{ {
ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(source);
@@ -128,6 +163,12 @@ public class JobItemAssemblyService : IJobItemAssemblyService
createdAtUtc); createdAtUtc);
} }
/// <summary>
/// Builds <see cref="JobItemCoat"/> records from a saved <see cref="QuoteItem"/> during quote-to-job conversion.
/// Coat appearance (color name, code, finish) is resolved from the linked <see cref="InventoryItem"/> if available,
/// because the inventory record is the canonical source of truth for a product's appearance —
/// the values typed into the quote form may be incomplete or informal.
/// </summary>
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc) public IReadOnlyList<JobItemCoat> CreateJobItemCoats(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
{ {
ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(source);
@@ -160,6 +201,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
.ToList() ?? []; .ToList() ?? [];
} }
/// <summary>
/// Copies prep service records from a <see cref="QuoteItem"/> to a new job item during quote-to-job conversion.
/// </summary>
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc) public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
{ {
ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(source);
@@ -175,6 +219,12 @@ public class JobItemAssemblyService : IJobItemAssemblyService
createdAtUtc); createdAtUtc);
} }
/// <summary>
/// Creates a new <see cref="JobItem"/> by cloning an existing one — used for job templates
/// and rework duplication where an existing job line is reused on a new job.
/// Prices are copied as-is from the source; the job controller is responsible for repricing
/// if operating costs have changed since the original job was created.
/// </summary>
public JobItem CreateJobItem(JobItem source, int jobId, int companyId, DateTime createdAtUtc) public JobItem CreateJobItem(JobItem source, int jobId, int companyId, DateTime createdAtUtc)
{ {
ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(source);
@@ -214,6 +264,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
createdAtUtc); createdAtUtc);
} }
/// <summary>
/// Clones coat records from an existing <see cref="JobItem"/> onto a new job item.
/// PowderToOrder is copied verbatim (not recalculated) because the original job's powder
/// quantities may have been manually adjusted after initial calculation.
/// </summary>
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc) public IReadOnlyList<JobItemCoat> CreateJobItemCoats(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
{ {
ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(source);
@@ -242,6 +297,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
.ToList() ?? []; .ToList() ?? [];
} }
/// <summary>
/// Clones prep service records from an existing <see cref="JobItem"/> onto a new job item.
/// </summary>
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc) public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
{ {
ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(source);
@@ -257,6 +315,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
createdAtUtc); createdAtUtc);
} }
/// <summary>
/// Single construction point for all <see cref="JobItem"/> creation paths.
/// Centralised here so that adding a new field only requires one code change, not three.
/// </summary>
private static JobItem BuildJobItem(JobItemSeed seed, int jobId, int companyId, DateTime createdAtUtc) private static JobItem BuildJobItem(JobItemSeed seed, int jobId, int companyId, DateTime createdAtUtc)
{ {
return new JobItem return new JobItem
@@ -293,6 +355,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
}; };
} }
/// <summary>
/// Single construction point for all <see cref="JobItemCoat"/> creation paths.
/// </summary>
private static JobItemCoat BuildJobItemCoat(JobItemCoatSeed seed, int jobItemId, int companyId, DateTime createdAtUtc) private static JobItemCoat BuildJobItemCoat(JobItemCoatSeed seed, int jobItemId, int companyId, DateTime createdAtUtc)
{ {
return new JobItemCoat return new JobItemCoat
@@ -315,6 +380,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
}; };
} }
/// <summary>
/// Single construction point for all <see cref="JobItemPrepService"/> creation paths.
/// Returns an empty list (not null) when <paramref name="seeds"/> is null so callers
/// can safely iterate without a null check.
/// </summary>
private static IReadOnlyList<JobItemPrepService> BuildJobItemPrepServices(IEnumerable<JobItemPrepServiceSeed>? seeds, int jobItemId, int companyId, DateTime createdAtUtc) private static IReadOnlyList<JobItemPrepService> BuildJobItemPrepServices(IEnumerable<JobItemPrepServiceSeed>? seeds, int jobItemId, int companyId, DateTime createdAtUtc)
{ {
return seeds? return seeds?
@@ -330,6 +400,18 @@ public class JobItemAssemblyService : IJobItemAssemblyService
.ToList() ?? []; .ToList() ?? [];
} }
/// <summary>
/// Returns the pounds of powder needed to coat a batch, preferring the pre-stored value
/// (which the user may have manually adjusted in the wizard) over a fresh recalculation.
///
/// Formula: (surfaceAreaSqFt × quantity) ÷ (coverageSqFtPerLb × transferEfficiency)
///
/// Industry defaults are applied when catalog data is missing:
/// - Coverage: 30 sqft/lb (typical for standard powder at 23 mil DFT)
/// - Transfer efficiency: 65% (industry average for electrostatic spray)
/// These are conservative defaults that slightly overestimate powder needed — intentional,
/// so the shop doesn't run short on a job.
/// </summary>
private static decimal? CalculatePowderToOrder(decimal? storedPowderToOrder, decimal surfaceAreaSqFt, decimal quantity, decimal coverageSqFtPerLb, decimal transferEfficiency) private static decimal? CalculatePowderToOrder(decimal? storedPowderToOrder, decimal surfaceAreaSqFt, decimal quantity, decimal coverageSqFtPerLb, decimal transferEfficiency)
{ {
if (storedPowderToOrder.HasValue && storedPowderToOrder.Value > 0) if (storedPowderToOrder.HasValue && storedPowderToOrder.Value > 0)
@@ -343,6 +425,12 @@ public class JobItemAssemblyService : IJobItemAssemblyService
return Math.Round((surfaceAreaSqFt * quantity) / (coverage * efficiency), 2); return Math.Round((surfaceAreaSqFt * quantity) / (coverage * efficiency), 2);
} }
/// <summary>
/// Resolves the display appearance (color name, code, finish) for a coat, preferring the linked
/// <see cref="InventoryItem"/>'s values over whatever was typed into the quote form.
/// The inventory record is the canonical source of truth — the form values are used as a fallback
/// only when no inventory item is linked (e.g. custom/one-off powder).
/// </summary>
private static (string? ColorName, string? ColorCode, string? Finish) ResolveCoatAppearance( private static (string? ColorName, string? ColorCode, string? Finish) ResolveCoatAppearance(
string? colorName, string? colorName,
string? colorCode, string? colorCode,
@@ -355,6 +443,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
return (inventoryItem.Name, inventoryItem.ColorCode, inventoryItem.Finish); return (inventoryItem.Name, inventoryItem.ColorCode, inventoryItem.Finish);
} }
/// <summary>
/// Intermediate value object that normalises the three different source types
/// (DTO, QuoteItem, JobItem) into a single shape before the shared BuildJobItem factory method.
/// Using a seed class prevents subtle bugs where an overload forgets to map a new field.
/// </summary>
private sealed class JobItemSeed private sealed class JobItemSeed
{ {
public string Description { get; init; } = string.Empty; public string Description { get; init; } = string.Empty;
@@ -385,6 +478,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
public int? AiPredictionId { get; init; } public int? AiPredictionId { get; init; }
} }
/// <summary>Intermediate value object for coat creation — see <see cref="JobItemSeed"/> for rationale.</summary>
private sealed class JobItemCoatSeed private sealed class JobItemCoatSeed
{ {
public string CoatName { get; init; } = string.Empty; public string CoatName { get; init; } = string.Empty;
@@ -401,6 +495,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
public string? Notes { get; init; } public string? Notes { get; init; }
} }
/// <summary>Intermediate value object for prep service creation — see <see cref="JobItemSeed"/> for rationale.</summary>
private sealed class JobItemPrepServiceSeed private sealed class JobItemPrepServiceSeed
{ {
public int PrepServiceId { get; init; } public int PrepServiceId { get; init; }
@@ -6,6 +6,20 @@ using Microsoft.Extensions.Logging;
namespace PowderCoating.Application.Services; namespace PowderCoating.Application.Services;
/// <summary>
/// Orchestrates the full quote item assembly pipeline: pricing calculation, entity construction,
/// AI prediction tracking, and automatic inventory record creation for incoming powder orders.
///
/// This service sits above <see cref="PricingCalculationService"/> — it knows HOW to build and
/// persist quote entities, while PricingCalculationService knows HOW to compute dollar amounts.
/// Keeping them separate means pricing logic can be unit-tested without any entity construction concerns.
///
/// Key responsibilities:
/// - <see cref="ApplyPricingSnapshot"/> — stamps calculated totals onto the Quote entity so the
/// displayed price is frozen at quote time and won't change if operating costs are updated later.
/// - <see cref="CreateQuoteItemsAsync"/> — builds QuoteItem + coats + prep services for each DTO,
/// records AI prediction overrides, and auto-creates incoming inventory records when needed.
/// </summary>
public class QuotePricingAssemblyService : IQuotePricingAssemblyService public class QuotePricingAssemblyService : IQuotePricingAssemblyService
{ {
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
@@ -25,6 +39,11 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
_logger = logger; _logger = logger;
} }
/// <summary>
/// Writes the calculated pricing breakdown onto the <see cref="Quote"/> entity as a snapshot.
/// Snapshots are critical: once a quote is sent to a customer, operating cost changes must NOT
/// silently alter the quoted amounts — the snapshot preserves what was presented at the time.
/// </summary>
public void ApplyPricingSnapshot(Quote quote, QuotePricingResult pricingResult) public void ApplyPricingSnapshot(Quote quote, QuotePricingResult pricingResult)
{ {
ArgumentNullException.ThrowIfNull(quote); ArgumentNullException.ThrowIfNull(quote);
@@ -56,6 +75,12 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
quote.Total = pricingResult.Total; quote.Total = pricingResult.Total;
} }
/// <summary>
/// Builds and prices all <see cref="QuoteItem"/> entities from the incoming DTOs.
/// For each item: constructs the entity, calculates pricing, records whether the user overrode
/// an AI estimate, then attaches coats (including auto-creating incoming inventory entries when
/// the user selects a catalog powder not yet in their inventory) and prep services.
/// </summary>
public async Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync( public async Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync(
IEnumerable<CreateQuoteItemDto> itemDtos, IEnumerable<CreateQuoteItemDto> itemDtos,
int quoteId, int quoteId,
@@ -80,6 +105,13 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
return items; return items;
} }
/// <summary>
/// Routes a single item to the correct pricing path and stamps the result onto the entity.
/// Priority order matches the routing table in <see cref="PricingCalculationService.CalculateQuoteItemPriceAsync"/>:
/// AI items → Sales items → Catalog (no coats) → full calculation engine.
/// Keeping pricing logic in PricingCalculationService means this method only decides WHICH
/// path to take, never HOW to compute the price.
/// </summary>
private async Task ApplyPricingAsync(QuoteItem item, CreateQuoteItemDto itemDto, int companyId, decimal? ovenRateOverride) private async Task ApplyPricingAsync(QuoteItem item, CreateQuoteItemDto itemDto, int companyId, decimal? ovenRateOverride)
{ {
if (itemDto.IsAiItem && itemDto.ManualUnitPrice.HasValue && itemDto.ManualUnitPrice.Value > 0) if (itemDto.IsAiItem && itemDto.ManualUnitPrice.HasValue && itemDto.ManualUnitPrice.Value > 0)
@@ -127,6 +159,12 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
ApplyCalculatedPricing(item, pricing); ApplyCalculatedPricing(item, pricing);
} }
/// <summary>
/// Builds <see cref="QuoteItemCoat"/> entities for a single item, including per-coat pricing.
/// If a coat has <c>AddAsIncoming = true</c> and references a catalog item but not an inventory
/// item, an incoming <see cref="InventoryItem"/> is auto-created so the shop can track the powder
/// order and receive it later — see <see cref="CreateIncomingInventoryItemAsync"/> for details.
/// </summary>
private async Task<List<QuoteItemCoat>> BuildQuoteItemCoatsAsync(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc) private async Task<List<QuoteItemCoat>> BuildQuoteItemCoatsAsync(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
{ {
if (itemDto.Coats == null || itemDto.Coats.Count == 0) if (itemDto.Coats == null || itemDto.Coats.Count == 0)
@@ -158,6 +196,7 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
return coats; return coats;
} }
/// <summary>Constructs <see cref="QuoteItemPrepService"/> entities from the item DTO's prep service list.</summary>
private static List<QuoteItemPrepService> BuildQuoteItemPrepServices(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc) private static List<QuoteItemPrepService> BuildQuoteItemPrepServices(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
{ {
if (itemDto.PrepServices == null || itemDto.PrepServices.Count == 0) if (itemDto.PrepServices == null || itemDto.PrepServices.Count == 0)
@@ -175,6 +214,11 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
.ToList(); .ToList();
} }
/// <summary>
/// Constructs a bare <see cref="QuoteItem"/> entity from the DTO — no pricing or coats yet.
/// Pricing is applied separately by <see cref="ApplyPricingAsync"/> to keep the construction
/// and calculation steps distinct and individually testable.
/// </summary>
private static QuoteItem BuildQuoteItem(CreateQuoteItemDto itemDto, int quoteId, int companyId, DateTime createdAtUtc) private static QuoteItem BuildQuoteItem(CreateQuoteItemDto itemDto, int quoteId, int companyId, DateTime createdAtUtc)
{ {
return new QuoteItem return new QuoteItem
@@ -204,6 +248,7 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
}; };
} }
/// <summary>Constructs a <see cref="QuoteItemCoat"/> entity from the coat DTO. Per-coat pricing is applied by the caller.</summary>
private static QuoteItemCoat BuildQuoteItemCoat(CreateQuoteItemCoatDto coatDto, int companyId, DateTime createdAtUtc) private static QuoteItemCoat BuildQuoteItemCoat(CreateQuoteItemCoatDto coatDto, int companyId, DateTime createdAtUtc)
{ {
return new QuoteItemCoat return new QuoteItemCoat
@@ -225,6 +270,10 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
}; };
} }
/// <summary>
/// Stamps the pricing result onto the quote item entity.
/// Broken out as a separate method because it's called from multiple branches of ApplyPricingAsync.
/// </summary>
private static void ApplyCalculatedPricing(QuoteItem item, QuoteItemPricingResult pricing) private static void ApplyCalculatedPricing(QuoteItem item, QuoteItemPricingResult pricing)
{ {
item.UnitPrice = pricing.UnitPrice; item.UnitPrice = pricing.UnitPrice;
@@ -234,6 +283,13 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
item.ItemEquipmentCost = pricing.EquipmentCost; item.ItemEquipmentCost = pricing.EquipmentCost;
} }
/// <summary>
/// Checks whether the user changed the AI's surface area or price estimates before saving,
/// and sets <c>UserOverrodeEstimate = true</c> on the prediction record if they did.
/// This flag feeds the AI analytics reports — over time it reveals how accurate the AI is
/// and whether certain item types consistently need manual correction.
/// A tolerance of $0.01 / 0.01 sqft is used to ignore floating-point rounding noise.
/// </summary>
private async Task UpdateAiPredictionOverrideAsync(CreateQuoteItemDto itemDto, decimal finalUnitPrice) private async Task UpdateAiPredictionOverrideAsync(CreateQuoteItemDto itemDto, decimal finalUnitPrice)
{ {
if (!itemDto.AiPredictionId.HasValue) return; if (!itemDto.AiPredictionId.HasValue) return;
@@ -247,6 +303,23 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
prediction.UpdatedAt = DateTime.UtcNow; prediction.UpdatedAt = DateTime.UtcNow;
} }
/// <summary>
/// Auto-creates an "incoming" <see cref="InventoryItem"/> when a user selects a powder from the
/// platform catalog that doesn't yet exist in their company's inventory.
///
/// WHY this exists: shops often quote jobs using powders they haven't ordered yet. Rather than
/// forcing the user to manually add the powder to inventory before quoting, we create an
/// IsIncoming=true record on their behalf. The shop can then receive the actual order against
/// this record later (updating quantity + receive date) without losing the link to the original quote.
///
/// The AI augmentation step (LookupByUrlAsync) fills in technical specs (cure temp/time, coverage,
/// color families, etc.) that may be missing from the scraped catalog JSON. It is best-effort —
/// if it fails, the item is still created with whatever data the catalog has.
///
/// After creation, <c>coatDto.PowderCostPerLb</c> is cleared so the pricing engine treats this
/// as an inventory-linked coat (not a custom powder), ensuring future repricings use the
/// inventory unit cost rather than the now-stale manual price from the quote form.
/// </summary>
private async Task<int?> CreateIncomingInventoryItemAsync(CreateQuoteItemCoatDto coatDto, int companyId) private async Task<int?> CreateIncomingInventoryItemAsync(CreateQuoteItemCoatDto coatDto, int companyId)
{ {
try try
@@ -92,7 +92,11 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
if (companyIdClaim != null && int.TryParse(companyIdClaim, out int companyId)) if (companyIdClaim != null && int.TryParse(companyIdClaim, out int companyId))
return 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 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; 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 start = startDate.Date;
var end = endDate.Date.AddDays(1).AddTicks(-1); var end = endDate.Date.AddDays(1).AddTicks(-1);
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// ── Load data ───────────────────────────────────────────────────────── // ── Load data ─────────────────────────────────────────────────────────
var invoices = (await _unitOfWork.Invoices.FindAsync( var invoices = (await _unitOfWork.Invoices.FindAsync(
i => i.InvoiceDate >= start && i.InvoiceDate <= end, i => i.CompanyId == companyId && i.InvoiceDate >= start && i.InvoiceDate <= end,
false, false,
i => i.InvoiceItems, i => i.InvoiceItems,
i => i.Payments, i => i.Payments,
@@ -72,7 +73,7 @@ public class AccountingExportController : Controller
.ToList(); .ToList();
var expenses = (await _unitOfWork.Expenses.FindAsync( var expenses = (await _unitOfWork.Expenses.FindAsync(
e => e.Date >= start && e.Date <= end, e => e.CompanyId == companyId && e.Date >= start && e.Date <= end,
false, false,
e => e.Vendor, e => e.Vendor,
e => e.ExpenseAccount, e => e.ExpenseAccount,
@@ -82,7 +83,7 @@ public class AccountingExportController : Controller
var bills = await _unitOfWork.Bills.GetForDateRangeAsync(start, end); 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) .OrderBy(c => c.CompanyName ?? c.ContactFirstName)
.ToList(); .ToList();
@@ -486,9 +486,12 @@ public class AppointmentsController : Controller
try try
{ {
var events = new List<CalendarEventDto>(); var events = new List<CalendarEventDto>();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// 1. Fetch appointments in date range // 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.Customer,
a => a.AppointmentType, a => a.AppointmentType,
a => a.AppointmentStatus); a => a.AppointmentStatus);
@@ -501,7 +504,9 @@ public class AppointmentsController : Controller
events.AddRange(appointmentEvents); events.AddRange(appointmentEvents);
// 2. Fetch maintenance records in date range // 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); m => m.Equipment);
var maintenanceRecords = allMaintenanceRecords var maintenanceRecords = allMaintenanceRecords
@@ -539,7 +544,9 @@ public class AppointmentsController : Controller
} }
// 3. Fetch jobs and add as all-day events // 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.Customer,
j => j.JobStatus); j => j.JobStatus);
@@ -746,13 +753,16 @@ public class AppointmentsController : Controller
try try
{ {
var terminalCodes = new[] { AppConstants.StatusCodes.Job.Completed, AppConstants.StatusCodes.Job.Delivered, AppConstants.StatusCodes.Job.Cancelled }; 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); j => j.Customer, j => j.JobStatus, j => j.JobItems);
// Load coats separately — filter by JobItemId using already-loaded item IDs // Load coats separately — filter by JobItemId using already-loaded item IDs
var jobItemIds = allJobs.SelectMany(j => j.JobItems.Select(i => i.Id)).ToList(); var jobItemIds = allJobs.SelectMany(j => j.JobItems.Select(i => i.Id)).ToList();
var allCoats = await _unitOfWork.JobItemCoats.FindAsync( var allCoats = await _unitOfWork.JobItemCoats.FindAsync(
c => jobItemIds.Contains(c.JobItemId)); c => jobItemIds.Contains(c.JobItemId) && c.CompanyId == calCompanyId);
var coatsByItemId = allCoats var coatsByItemId = allCoats
.Where(c => !c.IsDeleted) .Where(c => !c.IsDeleted)
@@ -891,7 +901,9 @@ public class AppointmentsController : Controller
/// </summary> /// </summary>
private async Task PopulateCreateDropdowns() 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 var customerList = customers.Select(c => new
{ {
c.Id, c.Id,
@@ -903,19 +915,16 @@ public class AppointmentsController : Controller
.ToList(); .ToList();
ViewBag.Customers = new SelectList(customerList, "Id", "DisplayName"); ViewBag.Customers = new SelectList(customerList, "Id", "DisplayName");
// Use cached appointment types
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var types = await _lookupCache.GetAppointmentTypeLookupsAsync(companyId); var types = await _lookupCache.GetAppointmentTypeLookupsAsync(companyId);
ViewBag.AppointmentTypes = new SelectList(types.Where(t => t.IsActive).OrderBy(t => t.DisplayOrder), "Id", "DisplayName"); 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 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) .OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
.ToListAsync(); .ToListAsync();
ViewBag.Workers = new SelectList(workers.Select(u => new { u.Id, FullName = u.FullName }), "Id", "FullName"); 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"); 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 IUnitOfWork _unitOfWork;
private readonly IMapper _mapper; private readonly IMapper _mapper;
private readonly ITenantContext _tenantContext;
private readonly ILogger<CatalogCategoriesController> _logger; private readonly ILogger<CatalogCategoriesController> _logger;
public CatalogCategoriesController( public CatalogCategoriesController(
IUnitOfWork unitOfWork, IUnitOfWork unitOfWork,
IMapper mapper, IMapper mapper,
ITenantContext tenantContext,
ILogger<CatalogCategoriesController> logger) ILogger<CatalogCategoriesController> logger)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_mapper = mapper; _mapper = mapper;
_tenantContext = tenantContext;
_logger = logger; _logger = logger;
} }
@@ -52,8 +55,9 @@ namespace PowderCoating.Web.Controllers
{ {
try try
{ {
var indexCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var categories = await _unitOfWork.CatalogCategories var categories = await _unitOfWork.CatalogCategories
.GetAllAsync(false, .FindAsync(c => c.CompanyId == indexCompanyId, false,
c => c.ParentCategory, c => c.ParentCategory,
c => c.SubCategories, c => c.SubCategories,
c => c.Items); c => c.Items);
@@ -164,7 +168,8 @@ namespace PowderCoating.Web.Controllers
if (ModelState.IsValid) if (ModelState.IsValid)
{ {
// Check for duplicate category name under the same parent (case-insensitive) // 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 => var existingCategory = allCategories.FirstOrDefault(c =>
c.Name.Equals(dto.Name.Trim(), StringComparison.OrdinalIgnoreCase) && c.Name.Equals(dto.Name.Trim(), StringComparison.OrdinalIgnoreCase) &&
c.ParentCategoryId == dto.ParentCategoryId); c.ParentCategoryId == dto.ParentCategoryId);
@@ -272,7 +277,8 @@ namespace PowderCoating.Web.Controllers
if (nameChanged || parentChanged) 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 => var existingCategory = allCategories.FirstOrDefault(c =>
c.Id != id && c.Id != id &&
c.Name.Equals(dto.Name.Trim(), StringComparison.OrdinalIgnoreCase) && c.Name.Equals(dto.Name.Trim(), StringComparison.OrdinalIgnoreCase) &&
@@ -444,7 +450,8 @@ namespace PowderCoating.Web.Controllers
var trimmedName = request.Name.Trim(); var trimmedName = request.Name.Trim();
// Check for duplicate category name under the same parent (case-insensitive) // 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 => var existingCategory = allCategories.FirstOrDefault(c =>
c.Name.Equals(trimmedName, StringComparison.OrdinalIgnoreCase) && c.Name.Equals(trimmedName, StringComparison.OrdinalIgnoreCase) &&
c.ParentCategoryId == request.ParentCategoryId); c.ParentCategoryId == request.ParentCategoryId);
@@ -500,8 +507,9 @@ namespace PowderCoating.Web.Controllers
{ {
try try
{ {
var treeCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var categories = await _unitOfWork.CatalogCategories 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 // Build tree from root categories
var rootCategories = categories var rootCategories = categories
@@ -535,7 +543,8 @@ namespace PowderCoating.Web.Controllers
{ {
try 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) // Build hierarchical list (parents before children)
var hierarchicalList = new List<CatalogCategory>(); var hierarchicalList = new List<CatalogCategory>();
@@ -573,7 +582,8 @@ namespace PowderCoating.Web.Controllers
/// </param> /// </param>
private async Task PopulateParentCategoryDropdown(int? excludeCategoryId = null) 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 // Exclude the current category and its descendants to prevent circular references
var excludedIds = new HashSet<int>(); var excludedIds = new HashSet<int>();
@@ -700,7 +710,8 @@ namespace PowderCoating.Web.Controllers
if (categoryId == newParentId) if (categoryId == newParentId)
return true; 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); var current = categories.FirstOrDefault(c => c.Id == newParentId);
while (current != null) while (current != null)
@@ -83,7 +83,8 @@ namespace PowderCoating.Web.Controllers
try try
{ {
// Get all categories with their items // 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(); var allItems = allCategories.SelectMany(c => c.Items).ToList();
// Apply search filter // Apply search filter
@@ -578,7 +579,8 @@ namespace PowderCoating.Web.Controllers
return Json(new List<object>()); 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 search = searchTerm.ToLower();
var items = allItems var items = allItems
@@ -694,7 +696,8 @@ namespace PowderCoating.Web.Controllers
/// </summary> /// </summary>
private async Task PopulateCategoryDropdown() 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) // Build hierarchical list (parents before children)
var hierarchicalList = new List<CatalogCategory>(); 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"). // 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 // 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". // "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); .ToDictionary(c => c.Id);
// Load company operating costs // Load company operating costs
@@ -142,10 +142,10 @@ public class CompanySettingsController : Controller
&& !connectClientId.Contains("your_connect_client_id_here", StringComparison.OrdinalIgnoreCase); && !connectClientId.Contains("your_connect_client_id_here", StringComparison.OrdinalIgnoreCase);
// Load notification templates for inline tab // 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()); var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList());
if (seeded > 0) if (seeded > 0)
existing = await _unitOfWork.NotificationTemplates.GetAllAsync(); existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);
dto.NotificationTemplates = existing dto.NotificationTemplates = existing
.OrderBy(t => (int)t.NotificationType).ThenBy(t => (int)t.Channel) .OrderBy(t => (int)t.NotificationType).ThenBy(t => (int)t.Channel)
@@ -755,8 +755,8 @@ public class CompanySettingsController : Controller
var costs = company.OperatingCosts; var costs = company.OperatingCosts;
var ovens = (await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive)).OrderBy(o => o.DisplayOrder).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)).ToList(); var coatingCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsCoating && c.CompanyId == companyId.Value)).ToList();
var sb = new System.Text.StringBuilder(); var sb = new System.Text.StringBuilder();
@@ -920,7 +920,8 @@ public class CompanySettingsController : Controller
{ {
try 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 sortedStatuses = statuses.OrderBy(s => s.DisplayOrder).ToList();
var dtos = _mapper.Map<List<JobStatusLookupDto>>(sortedStatuses); var dtos = _mapper.Map<List<JobStatusLookupDto>>(sortedStatuses);
@@ -1071,7 +1072,8 @@ public class CompanySettingsController : Controller
if (!ModelState.IsValid) if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" }); 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++) for (int i = 0; i < dto.OrderedIds.Count; i++)
{ {
@@ -1084,7 +1086,6 @@ public class CompanySettingsController : Controller
} }
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId.HasValue) _lookupCache.InvalidateCompanyCache(companyId.Value); if (companyId.HasValue) _lookupCache.InvalidateCompanyCache(companyId.Value);
_logger.LogInformation("Job statuses reordered"); _logger.LogInformation("Job statuses reordered");
@@ -1113,7 +1114,8 @@ public class CompanySettingsController : Controller
{ {
try 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 sortedPriorities = priorities.OrderBy(p => p.DisplayOrder).ToList();
var dtos = _mapper.Map<List<JobPriorityLookupDto>>(sortedPriorities); var dtos = _mapper.Map<List<JobPriorityLookupDto>>(sortedPriorities);
@@ -1258,7 +1260,8 @@ public class CompanySettingsController : Controller
if (!ModelState.IsValid) if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" }); 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++) for (int i = 0; i < dto.OrderedIds.Count; i++)
{ {
@@ -1297,7 +1300,8 @@ public class CompanySettingsController : Controller
{ {
try 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 sortedStatuses = statuses.OrderBy(s => s.DisplayOrder).ToList();
var dtos = _mapper.Map<List<QuoteStatusLookupDto>>(sortedStatuses); var dtos = _mapper.Map<List<QuoteStatusLookupDto>>(sortedStatuses);
@@ -1478,7 +1482,8 @@ public class CompanySettingsController : Controller
if (!ModelState.IsValid) if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" }); 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++) for (int i = 0; i < dto.OrderedIds.Count; i++)
{ {
@@ -1517,7 +1522,8 @@ public class CompanySettingsController : Controller
{ {
try 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 sortedServices = services.OrderBy(s => s.DisplayOrder).ToList();
var dtos = _mapper.Map<List<PrepServiceDto>>(sortedServices); var dtos = _mapper.Map<List<PrepServiceDto>>(sortedServices);
@@ -1639,7 +1645,8 @@ public class CompanySettingsController : Controller
if (!ModelState.IsValid) if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" }); 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++) for (int i = 0; i < dto.OrderedIds.Count; i++)
{ {
@@ -1812,7 +1819,8 @@ public class CompanySettingsController : Controller
{ {
try 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 sortedTypes = types.OrderBy(t => t.DisplayOrder).ToList();
var dtos = _mapper.Map<List<AppointmentTypeLookupDto>>(sortedTypes); var dtos = _mapper.Map<List<AppointmentTypeLookupDto>>(sortedTypes);
@@ -1956,7 +1964,8 @@ public class CompanySettingsController : Controller
if (!ModelState.IsValid) if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" }); 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++) for (int i = 0; i < dto.OrderedIds.Count; i++)
{ {
@@ -1996,7 +2005,8 @@ public class CompanySettingsController : Controller
{ {
try 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 sortedCategories = categories.OrderBy(c => c.DisplayOrder).ToList();
var dtos = _mapper.Map<List<InventoryCategoryLookupDto>>(sortedCategories); var dtos = _mapper.Map<List<InventoryCategoryLookupDto>>(sortedCategories);
@@ -2132,7 +2142,8 @@ public class CompanySettingsController : Controller
if (!ModelState.IsValid) if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" }); 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++) for (int i = 0; i < dto.OrderedIds.Count; i++)
{ {
@@ -2349,12 +2360,12 @@ public class CompanySettingsController : Controller
if (companyId == null) return RedirectToAction(nameof(Index)); if (companyId == null) return RedirectToAction(nameof(Index));
// Load all existing templates for this company // 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 // Auto-seed any missing canonical combinations
var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList()); var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList());
if (seeded > 0) 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) var dtos = existing.OrderBy(t => (int)t.NotificationType).ThenBy(t => (int)t.Channel)
.Select(t => new NotificationTemplateDto .Select(t => new NotificationTemplateDto
@@ -315,7 +315,8 @@ public class CreditMemosController : Controller
private async Task PopulateCustomersAsync(int? selectedId) 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 ViewBag.Customers = customers
.OrderBy(c => c.CompanyName ?? $"{c.ContactFirstName} {c.ContactLastName}".Trim()) .OrderBy(c => c.CompanyName ?? $"{c.ContactFirstName} {c.ContactLastName}".Trim())
.Select(c => new SelectListItem .Select(c => new SelectListItem
@@ -342,14 +342,16 @@ public class DashboardController : Controller
TipOfTheDay = data.TipOfTheDay 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 // Dropdowns for the "Add Custom Powder to Inventory" modal
var inventoryCategories = (await _unitOfWork.InventoryCategoryLookups.GetAllAsync()) var inventoryCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsActive && c.CompanyId == companyId))
.Where(c => c.IsActive)
.OrderBy(c => c.DisplayOrder) .OrderBy(c => c.DisplayOrder)
.Select(c => new { c.Id, c.DisplayName }) .Select(c => new { c.Id, c.DisplayName })
.ToList(); .ToList();
var vendors = (await _unitOfWork.Vendors.GetAllAsync()) var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.IsActive && v.CompanyId == companyId))
.Where(v => v.IsActive)
.OrderBy(v => v.CompanyName) .OrderBy(v => v.CompanyName)
.Select(v => new { v.Id, v.CompanyName }) .Select(v => new { v.Id, v.CompanyName })
.ToList(); .ToList();
@@ -357,7 +359,6 @@ public class DashboardController : Controller
ViewBag.VendorList = vendors; ViewBag.VendorList = vendors;
// Config health check — surface setup gaps to company admins // Config health check — surface setup gaps to company admins
var currentCompanyId = _tenantContext.GetCurrentCompanyId();
if (currentCompanyId.HasValue) if (currentCompanyId.HasValue)
{ {
ViewBag.ConfigHealth = await _configHealth.CheckAsync(currentCompanyId.Value); 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); i => i.Coats.Any(c => c.Id == coatId), false, i => i.Job);
var companyId = jobItem?.Job?.CompanyId ?? _tenantContext.GetCurrentCompanyId() ?? 0; var companyId = jobItem?.Job?.CompanyId ?? _tenantContext.GetCurrentCompanyId() ?? 0;
// Check SKU uniqueness // Check SKU uniqueness within this company
if (await _unitOfWork.InventoryItems.AnyAsync(i => i.SKU == sku.Trim())) 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." }); return Json(new { success = false, message = $"SKU '{sku}' already exists in inventory." });
// Determine category display name for legacy field // Determine category display name for legacy field
@@ -160,7 +160,8 @@ public class InventoryController : Controller
var pagedResult = PagedResult<InventoryListDto>.From(gridRequest, itemDtos, totalCount); var pagedResult = PagedResult<InventoryListDto>.From(gridRequest, itemDtos, totalCount);
// Load all items once to compute sidebar stats and category list in memory // Load all items once to compute sidebar stats and 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.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.StatsLowStockCount = allItems.Count(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
ViewBag.StatsActiveCount = allItems.Count(i => i.IsActive); 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. // 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. // 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 var existingSkus = existingItems
.Where(i => !string.IsNullOrWhiteSpace(i.ManufacturerPartNumber) && i.Id != (currentId ?? 0)) .Where(i => !string.IsNullOrWhiteSpace(i.ManufacturerPartNumber) && i.Id != (currentId ?? 0))
.Select(i => i.ManufacturerPartNumber!.Trim().ToLower()) .Select(i => i.ManufacturerPartNumber!.Trim().ToLower())
@@ -1182,7 +1184,7 @@ public class InventoryController : Controller
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Find the default coating category to assign // 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 var coatingCategory = categories
.Where(c => c.IsActive && c.IsCoating) .Where(c => c.IsActive && c.IsCoating)
.OrderBy(c => c.DisplayOrder) .OrderBy(c => c.DisplayOrder)
@@ -1369,11 +1371,11 @@ public class InventoryController : Controller
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
ViewBag.AiInventoryAssistEnabled = await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId); 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"); ViewBag.Vendors = new SelectList(vendors.Where(s => s.IsActive).OrderBy(s => s.CompanyName), "Id", "CompanyName");
// Load categories from lookup table // Load categories from lookup table
var allCategories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync(); var allCategories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
var categories = allCategories var categories = allCategories
.Where(c => c.IsActive) .Where(c => c.IsActive)
.OrderBy(c => c.DisplayOrder) .OrderBy(c => c.DisplayOrder)
@@ -1738,7 +1740,8 @@ public class InventoryController : Controller
DateTime? dateTo, DateTime? dateTo,
string? typeFilter) 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 var itemList = allItems
.Where(i => i.IsActive || i.QuantityOnHand > 0) .Where(i => i.IsActive || i.QuantityOnHand > 0)
.OrderBy(i => i.Name) .OrderBy(i => i.Name)
@@ -2213,7 +2213,7 @@ public class InvoicesController : Controller
/// </summary> /// </summary>
private async Task PopulateCreateViewBagAsync(int companyId, string? selectedTerms = null) 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(); 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 // Expose company default tax rate and exempt customer IDs for client-side tax handling
@@ -36,7 +36,9 @@ public class JobTemplatesController : Controller
/// </summary> /// </summary>
public async Task<IActionResult> Index() 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, false,
t => t.Customer, t => t.Customer,
t => t.Items); t => t.Items);
@@ -499,13 +499,21 @@ public class JobsController : Controller
ViewBag.MaterialsUsed = allJobTransactions; ViewBag.MaterialsUsed = allJobTransactions;
// Inventory items for the manual log-material modal // Inventory items for the manual log-material modal
var inventoryItemsForModal = (await _unitOfWork.InventoryItems.GetAllAsync()) var inventoryItemsForModal = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == job.CompanyId))
.OrderBy(i => i.Name) .OrderBy(i => i.Name)
.Select(i => new { i.Id, i.Name, i.UnitOfMeasure, i.QuantityOnHand }) .Select(i => new { i.Id, i.Name, i.Manufacturer, i.UnitOfMeasure, i.QuantityOnHand })
.ToList(); .ToList();
ViewBag.InventoryItemsForModal = System.Text.Json.JsonSerializer.Serialize( var jsonOpts = new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase };
inventoryItemsForModal, ViewBag.InventoryItemsForModal = System.Text.Json.JsonSerializer.Serialize(inventoryItemsForModal, jsonOpts);
new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase });
// 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) // Pre-logged powder grouped by InventoryItemId (for Complete Job modal pre-fill)
ViewBag.PreLoggedPowder = allJobTransactions ViewBag.PreLoggedPowder = allJobTransactions
@@ -520,7 +528,7 @@ public class JobsController : Controller
ViewBag.JobPhotoMax = photoMax; ViewBag.JobPhotoMax = photoMax;
// Customer list for inline customer-change dropdown // 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 ViewBag.CustomerSelectList = allCustomers
.Where(c => c.IsActive) .Where(c => c.IsActive)
.Select(c => new SelectListItem .Select(c => new SelectListItem
@@ -626,7 +634,8 @@ public class JobsController : Controller
if (job == null) return NotFound(); 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(); .OrderBy(s => s.DisplayOrder).ToList();
ViewBag.AllStatuses = allStatuses; ViewBag.AllStatuses = allStatuses;
@@ -649,7 +658,7 @@ public class JobsController : Controller
if (job == null) return NotFound(); 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); var newStatus = allStatuses.FirstOrDefault(s => s.Id == newStatusId);
if (newStatus == null) return BadRequest("Invalid status."); if (newStatus == null) return BadRequest("Invalid status.");
@@ -837,7 +846,7 @@ public class JobsController : Controller
// Optionally advance status to In Preparation // Optionally advance status to In Preparation
if (advanceToInPreparation && jobToUpdate.JobStatus.StatusCode != AppConstants.StatusCodes.Job.InPreparation) 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); var inPrepStatus = allStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.InPreparation);
if (inPrepStatus != null) if (inPrepStatus != null)
{ {
@@ -894,7 +903,7 @@ public class JobsController : Controller
if (advanceToInPreparation && job.JobStatus.StatusCode != AppConstants.StatusCodes.Job.InPreparation && !job.JobStatus.IsTerminalStatus) 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); var inPrepStatus = allStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.InPreparation);
if (inPrepStatus != null) if (inPrepStatus != null)
{ {
@@ -1801,7 +1810,7 @@ public class JobsController : Controller
ViewBag.AiPhotoQuotesEnabled = await _subscriptionService.CanUseAiPhotoQuoteAsync(companyId); ViewBag.AiPhotoQuotesEnabled = await _subscriptionService.CanUseAiPhotoQuoteAsync(companyId);
await PopulateDropdowns(); await PopulateDropdowns();
await PopulatePrepServicesAsync(); await PopulatePrepServicesAsync(companyId);
var costs = await _pricingService.GetOperatingCostsAsync(companyId); var costs = await _pricingService.GetOperatingCostsAsync(companyId);
await PopulateJobItemDropDownsAsync(companyId, costs?.OvenOperatingCostPerHour ?? 45m); await PopulateJobItemDropDownsAsync(companyId, costs?.OvenOperatingCostPerHour ?? 45m);
ViewBag.TaxPercent = costs?.TaxPercent ?? 0m; ViewBag.TaxPercent = costs?.TaxPercent ?? 0m;
@@ -1821,7 +1830,9 @@ public class JobsController : Controller
/// </summary> /// </summary>
private async Task PopulateDropdowns() 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( ViewBag.Customers = new SelectList(
customers.Where(c => c.IsActive).Select(c => new customers.Where(c => c.IsActive).Select(c => new
{ {
@@ -1832,8 +1843,6 @@ public class JobsController : Controller
}).OrderBy(c => c.DisplayName), }).OrderBy(c => c.DisplayName),
"Id", "Id",
"DisplayName"); "DisplayName");
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var users = await _userManager.Users var users = await _userManager.Users
.Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null) .Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null)
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName) .OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
@@ -2215,13 +2224,13 @@ public class JobsController : Controller
/// Loads all active prep services into ViewBag for the item wizard's prep services step. /// 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. /// Prep services are ordered by DisplayOrder so they appear in the intended workflow sequence.
/// </summary> /// </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(); ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList();
_logger.LogInformation("Populated {Count} active prep services", prepServices.Count()); _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) ViewBag.BlastSetups = blastSetups.OrderBy(b => b.DisplayOrder)
.Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault }) .Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault })
.ToList(); .ToList();
@@ -3158,7 +3167,7 @@ public class JobsController : Controller
/// </summary> /// </summary>
private async Task PopulateJobItemDropDownsAsync(int companyId, decimal fallbackOvenRate) 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 ViewBag.InventoryCoatings = inventory
.Where(i => i.IsActive && i.InventoryCategory?.IsActive == true && i.InventoryCategory.IsCoating) .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) .OrderBy(i => i.IsIncoming ? 1 : 0).ThenBy(i => i.InventoryCategory!.DisplayOrder).ThenBy(i => i.ColorName ?? i.Name)
@@ -3178,12 +3187,12 @@ public class JobsController : Controller
isIncoming = i.IsIncoming isIncoming = i.IsIncoming
}).ToList(); }).ToList();
var vendors = await _unitOfWork.Vendors.GetAllAsync(false); var vendors = await _unitOfWork.Vendors.FindAsync(s => s.CompanyId == companyId, false);
ViewBag.Vendors = vendors ViewBag.Vendors = vendors
.Where(s => s.IsActive).OrderBy(s => s.CompanyName) .Where(s => s.IsActive).OrderBy(s => s.CompanyName)
.Select(s => new { value = s.Id.ToString(), text = s.CompanyName }).ToList(); .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 ViewBag.CatalogItems = catalogItems
.Where(i => i.IsActive) .Where(i => i.IsActive)
.OrderBy(i => i.Category.DisplayOrder).ThenBy(i => i.DisplayOrder) .OrderBy(i => i.Category.DisplayOrder).ThenBy(i => i.DisplayOrder)
@@ -3212,10 +3221,10 @@ public class JobsController : Controller
description = i.Description description = i.Description
}).ToList(); }).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(); 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) ViewBag.BlastSetups = blastSetupsForEditItems.OrderBy(b => b.DisplayOrder)
.Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault }) .Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault })
.ToList(); .ToList();
@@ -90,8 +90,8 @@ public class JobsPriorityController : Controller
.ToList(); .ToList();
// Get priorities and workers for modal options // Get priorities and workers for modal options
var priorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId);
var workers = await _userManager.Users var workers = await _userManager.Users
.Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null) .Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null)
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName) .OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
@@ -16,15 +16,18 @@ public class MaintenanceController : Controller
{ {
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper; private readonly IMapper _mapper;
private readonly ITenantContext _tenantContext;
private readonly ILogger<MaintenanceController> _logger; private readonly ILogger<MaintenanceController> _logger;
public MaintenanceController( public MaintenanceController(
IUnitOfWork unitOfWork, IUnitOfWork unitOfWork,
IMapper mapper, IMapper mapper,
ITenantContext tenantContext,
ILogger<MaintenanceController> logger) ILogger<MaintenanceController> logger)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_mapper = mapper; _mapper = mapper;
_tenantContext = tenantContext;
_logger = logger; _logger = logger;
} }
@@ -740,7 +743,8 @@ public class MaintenanceController : Controller
/// </summary> /// </summary>
private async Task PopulateViewBagAsync(int? selectedEquipmentId = null) 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( ViewBag.EquipmentList = new SelectList(
equipment.Where(e => e.IsActive).OrderBy(e => e.EquipmentName), equipment.Where(e => e.IsActive).OrderBy(e => e.EquipmentName),
"Id", "Id",
@@ -179,8 +179,9 @@ public class OvenSchedulerController : Controller
public async Task<IActionResult> Suggest([FromBody] SuggestRequest req) public async Task<IActionResult> Suggest([FromBody] SuggestRequest req)
{ {
var goal = req?.OptimizationGoal ?? "maximize_throughput"; 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) .Where(o => o.IsActive)
.OrderBy(o => o.DisplayOrder).ThenBy(o => o.Label) .OrderBy(o => o.DisplayOrder).ThenBy(o => o.Label)
.ToList(); .ToList();
@@ -188,10 +189,11 @@ public class OvenSchedulerController : Controller
if (!equipmentList.Any()) if (!equipmentList.Any())
return Json(new { success = false, error = "No active ovens found. Add Named Ovens in Settings → Operating Costs." }); 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 defaultCycleMinutes = companyCosts.FirstOrDefault()?.DefaultOvenCycleMinutes ?? 45;
var queueJobs = (await _unitOfWork.Jobs.GetAllAsync( var queueJobs = (await _unitOfWork.Jobs.FindAsync(
j => j.CompanyId == suggestCompanyId,
false, false,
j => j.Customer, j => j.Customer,
j => j.JobStatus, j => j.JobStatus,
@@ -265,7 +267,8 @@ public class OvenSchedulerController : Controller
if (req?.Batches == null || !req.Batches.Any()) if (req?.Batches == null || !req.Batches.Any())
return Json(new { success = false, error = "No batches provided." }); 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 defaultCycleMinutes = companyCosts.FirstOrDefault()?.DefaultOvenCycleMinutes ?? 45;
var createdBatches = new List<object>(); var createdBatches = new List<object>();
@@ -357,7 +360,8 @@ public class OvenSchedulerController : Controller
if (oven == null) if (oven == null)
return Json(new { success = false, error = "Oven not found." }); 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 defaultCycleMinutes = companyCosts.FirstOrDefault()?.DefaultOvenCycleMinutes ?? 45;
var batchNumber = await GenerateBatchNumberAsync(); var batchNumber = await GenerateBatchNumberAsync();
@@ -651,7 +655,8 @@ public class OvenSchedulerController : Controller
if (inOvenStatus != null) if (inOvenStatus != null)
{ {
var jobIds = batch.Items.Select(i => i.JobId).Distinct().ToHashSet(); 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) foreach (var job in jobs)
job.JobStatusId = inOvenStatus.Id; job.JobStatusId = inOvenStatus.Id;
} }
@@ -14,12 +14,14 @@ public class PricingTiersController : Controller
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper; private readonly IMapper _mapper;
private readonly ILogger<PricingTiersController> _logger; 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; _unitOfWork = unitOfWork;
_mapper = mapper; _mapper = mapper;
_logger = logger; _logger = logger;
_tenantContext = tenantContext;
} }
/// <summary> /// <summary>
@@ -27,8 +29,9 @@ public class PricingTiersController : Controller
/// </summary> /// </summary>
public async Task<IActionResult> Index() public async Task<IActionResult> Index()
{ {
var tiers = await _unitOfWork.PricingTiers.GetAllAsync(); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var customers = await _unitOfWork.Customers.GetAllAsync(); var tiers = await _unitOfWork.PricingTiers.FindAsync(t => t.CompanyId == companyId);
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
var customerCountByTier = customers var customerCountByTier = customers
.Where(c => c.PricingTierId.HasValue) .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 // 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 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 ViewBag.QuotingNotCalibrated = costs != null
&& !hasNamedSetups && !hasNamedSetups
&& costs.CompressorCfm == 0 && costs.CompressorCfm == 0
@@ -441,7 +441,7 @@ public class QuotesController : Controller
ViewBag.Deposits = quoteDeposits; ViewBag.Deposits = quoteDeposits;
// Customer list for inline customer-change dropdown // 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 ViewBag.CustomerSelectList = allCustomers
.Where(c => c.IsActive) .Where(c => c.IsActive)
.Select(c => new SelectListItem .Select(c => new SelectListItem
@@ -2430,7 +2430,7 @@ public class QuotesController : Controller
ViewBag.QuotePhotosEnabled = quotePhotoMax != 0; // 0 = feature disabled for this plan ViewBag.QuotePhotosEnabled = quotePhotoMax != 0; // 0 = feature disabled for this plan
// Customers // Customers
var customers = await _unitOfWork.Customers.GetAllAsync(); var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
ViewBag.Customers = customers ViewBag.Customers = customers
.Select(c => new SelectListItem .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 // 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 ViewBag.InventoryCoatings = inventory
.Where(i => i.IsActive && i.InventoryCategory?.IsActive == true && i.InventoryCategory.IsCoating) .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) .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(); }).ToList();
// Vendors // Vendors
var vendors = await _unitOfWork.Vendors.GetAllAsync(false); var vendors = await _unitOfWork.Vendors.FindAsync(s => s.CompanyId == companyId, false);
ViewBag.Vendors = vendors ViewBag.Vendors = vendors
.Where(s => s.IsActive).OrderBy(s => s.CompanyName) .Where(s => s.IsActive).OrderBy(s => s.CompanyName)
.Select(s => new { value = s.Id.ToString(), text = s.CompanyName }).ToList(); .Select(s => new { value = s.Id.ToString(), text = s.CompanyName }).ToList();
// Catalog items // 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 ViewBag.CatalogItems = catalogItems
.Where(i => i.IsActive) .Where(i => i.IsActive)
.OrderBy(i => i.Category.DisplayOrder).ThenBy(i => i.DisplayOrder) .OrderBy(i => i.Category.DisplayOrder).ThenBy(i => i.DisplayOrder)
@@ -2528,11 +2528,11 @@ public class QuotesController : Controller
}).ToList(); }).ToList();
// Prep services // 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(); ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList();
// Blast setups for wizard dropdown // 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) ViewBag.BlastSetups = blastSetups.OrderBy(b => b.DisplayOrder)
.Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault }) .Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault })
.ToList(); .ToList();
@@ -2599,7 +2599,8 @@ public class QuotesController : Controller
/// </summary> /// </summary>
private async Task PopulatePricingTiersDropDownAsync() 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) ViewBag.PricingTiers = pricingTiers.OrderBy(pt => pt.TierName)
.Select(pt => new SelectListItem .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 // 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. // no-tracking children (which may share InventoryItem instances) causes EF identity conflicts.
// Get default job statuses and priorities // Get default job statuses and priorities — scope to quote's company for defense-in-depth
var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync(); var jobStatuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == quote.CompanyId);
var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync(); var jobPriorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == quote.CompanyId);
var approvedStatus = jobStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Approved); var approvedStatus = jobStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Approved);
var normalPriority = jobPriorities.FirstOrDefault(p => p.PriorityCode == "NORMAL"); var normalPriority = jobPriorities.FirstOrDefault(p => p.PriorityCode == "NORMAL");
var rushPriority = jobPriorities.FirstOrDefault(p => p.PriorityCode == "RUSH"); var rushPriority = jobPriorities.FirstOrDefault(p => p.PriorityCode == "RUSH");
@@ -3347,7 +3348,7 @@ public class QuotesController : Controller
CompanyBlastSetup? selectedBlastSetup = null; CompanyBlastSetup? selectedBlastSetup = null;
if (request.BlastSetupId.HasValue) 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(); 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> /// <summary>Lists all recurring templates for the current company, active first then by name.</summary>
public async Task<IActionResult> Index() 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()); 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> /// <summary>Loads dropdowns for vendors, accounts, and payment methods into ViewBag.</summary>
private async Task PopulateDropDownsAsync() 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) ViewBag.Vendors = vendors.OrderBy(v => v.CompanyName)
.Select(v => new SelectListItem(v.CompanyName, v.Id.ToString())).ToList(); .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 ViewBag.APAccounts = accounts
.Where(a => a.AccountSubType == AccountSubType.AccountsPayable) .Where(a => a.AccountSubType == AccountSubType.AccountsPayable)
.OrderBy(a => a.AccountNumber) .OrderBy(a => a.AccountNumber)
@@ -11,6 +11,7 @@ using PowderCoating.Core.Interfaces;
using PowderCoating.Core.Entities; using PowderCoating.Core.Entities;
using PowderCoating.Shared.Constants; using PowderCoating.Shared.Constants;
using PowderCoating.Web.ViewModels.Reports; using PowderCoating.Web.ViewModels.Reports;
using System.Security.Claims;
namespace PowderCoating.Web.Controllers; namespace PowderCoating.Web.Controllers;
@@ -25,8 +26,9 @@ public class ReportsController : Controller
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly IAccountingAiService _accountingAi; private readonly IAccountingAiService _accountingAi;
private readonly IAiUsageLogger _usageLogger; 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; _unitOfWork = unitOfWork;
_logger = logger; _logger = logger;
@@ -36,6 +38,7 @@ public class ReportsController : Controller
_userManager = userManager; _userManager = userManager;
_accountingAi = accountingAi; _accountingAi = accountingAi;
_usageLogger = usageLogger; _usageLogger = usageLogger;
_tenantContext = tenantContext;
} }
/// <summary> /// <summary>
@@ -79,27 +82,26 @@ public class ReportsController : Controller
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" }; var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
"MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" }; "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 // Load only necessary data — all explicitly scoped to this company
// Jobs: Load all jobs (we need various status filters and the collection is needed for job status distribution) var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority, j => j.AssignedUser)).ToList();
// 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();
// Quotes: Load all quotes (needed for quote status distribution and conversion funnel) // 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) // 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 // 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 // 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 // Appointments: Filter to relevant date range at DB level
var appointments = (await _unitOfWork.Appointments.FindAsync( var appointments = (await _unitOfWork.Appointments.FindAsync(
a => a.ScheduledStartTime >= startDate, a => a.CompanyId == companyId && a.ScheduledStartTime >= startDate,
false, false,
a => a.Customer, a => a.Customer,
a => a.AppointmentType, a => a.AppointmentType,
@@ -108,7 +110,7 @@ public class ReportsController : Controller
// Users with assigned jobs/appointments will be loaded below when building worker stats // Users with assigned jobs/appointments will be loaded below when building worker stats
// CatalogItems: Load all for category distribution // 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 === // === OVERVIEW METRICS ===
var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList(); 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()); .ToDictionary(g => g.Key, g => g.Count());
// === FINANCIAL ANALYTICS === // === 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 activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
var totalInvoiced = activeInvoices.Sum(i => i.Total); var totalInvoiced = activeInvoices.Sum(i => i.Total);
@@ -781,7 +783,7 @@ public class ReportsController : Controller
// === POWDER CONSUMPTION VS PURCHASE === // === POWDER CONSUMPTION VS PURCHASE ===
var allInventoryTransactions = (await _unitOfWork.InventoryTransactions var allInventoryTransactions = (await _unitOfWork.InventoryTransactions
.GetAllAsync(false, t => t.InventoryItem)) .FindAsync(t => t.CompanyId == companyId, false, t => t.InventoryItem))
.ToList(); .ToList();
var powderConsumptionItems = allInventoryTransactions var powderConsumptionItems = allInventoryTransactions
@@ -1309,14 +1311,15 @@ public class ReportsController : Controller
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" }; var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
"MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" }; "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 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.GetAllAsync()).ToList(); var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)).ToList();
var quotes = (await _unitOfWork.Quotes.GetAllAsync(false, q => q.QuoteStatus)).ToList(); var quotes = (await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId, false, q => q.QuoteStatus)).ToList();
var equipment = (await _unitOfWork.Equipment.GetAllAsync()).ToList(); var equipment = (await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId)).ToList();
var inventory = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList(); var inventory = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
var allAppointments = await _unitOfWork.Appointments.GetAllAsync(false, a => a.AppointmentStatus); var allAppointments = await _unitOfWork.Appointments.FindAsync(a => a.CompanyId == companyId && a.ScheduledStartTime >= startDate, false, a => a.AppointmentStatus);
var appointments = allAppointments.Where(a => a.ScheduledStartTime >= startDate).ToList(); var appointments = allAppointments.ToList();
var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList(); var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
var activeJobs = jobs.Where(j => activeStatusCodes.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 now = DateTime.UtcNow;
var startDate = now.AddMonths(-months); var startDate = now.AddMonths(-months);
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" }; 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 completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
var inRange = completedJobs.Where(j => j.UpdatedAt >= startDate).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()); 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 completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
"MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" }; "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 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.GetAllAsync()).ToList(); var equipment = (await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId)).ToList();
var inventory = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList(); var inventory = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
var allAppts = await _unitOfWork.Appointments.GetAllAsync(false, a => a.AppointmentType, a => a.AppointmentStatus); var allAppts = await _unitOfWork.Appointments.FindAsync(a => a.CompanyId == companyId && a.ScheduledStartTime >= startDate, false, a => a.AppointmentType, a => a.AppointmentStatus);
var appointments = allAppts.Where(a => a.ScheduledStartTime >= startDate).ToList(); var appointments = allAppts.ToList();
var activeJobs = jobs.Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList(); var activeJobs = jobs.Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
var completedJobs = jobs.Where(j => completedStatusCodes.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 now = DateTime.UtcNow;
var startDate = now.AddMonths(-months); var startDate = now.AddMonths(-months);
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" }; var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
var customers = (await _unitOfWork.Customers.GetAllAsync()).ToList(); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var quotes = (await _unitOfWork.Quotes.GetAllAsync(false, q => q.QuoteStatus)).ToList(); var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)).ToList();
var catalogItems = (await _unitOfWork.CatalogItems.GetAllAsync(false, c => c.Category)).ToList(); var quotes = (await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId, false, q => q.QuoteStatus)).ToList();
var completedJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)) 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(); .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()); 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)); if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var today = DateTime.Today; 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 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 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(); 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>(); var monthLabels = new List<string>(); var monthlyBillsPaid = new List<decimal>(); var monthlyDirectExpenses = new List<decimal>();
// Also load collected payments for P&L comparison // 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 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>(); var plRevenue = new List<decimal>(); var plExpenses = new List<decimal>(); var plNet = new List<decimal>();
for (var i = months - 1; i >= 0; i--) for (var i = months - 1; i >= 0; i--)
@@ -1609,8 +1617,10 @@ public class ReportsController : Controller
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var startDate = now.AddMonths(-months); var startDate = now.AddMonths(-months);
var powderTransactions = (await _unitOfWork.InventoryTransactions.GetAllAsync(false, t => t.InventoryItem)) var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
.Where(t => t.TransactionType == InventoryTransactionType.JobUsage && t.TransactionDate >= startDate).ToList(); 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) 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() }) .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> /// <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) 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 activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
var items = activeInvoices.Where(i => i.Customer != null) 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 }) .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 now = DateTime.UtcNow;
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" }; var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
var customers = (await _unitOfWork.Customers.GetAllAsync()).ToList(); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var completedJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.JobStatus)).Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList(); 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 items = customers.Where(c => c.IsActive).Select(c =>
{ {
var cJobs = completedJobs.Where(j => j.CustomerId == c.Id).ToList(); var cJobs = completedJobs.Where(j => j.CustomerId == c.Id).ToList();
@@ -1682,7 +1694,8 @@ public class ReportsController : Controller
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" }; 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 allStatusHistory = await _operationalReports.GetAllJobStatusHistoryAsync();
var historyByJob = allStatusHistory.GroupBy(h => h.JobId).ToDictionary(g => g.Key, g => g.OrderBy(h => h.ChangedDate).ToList()); 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" }; 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 now = DateTime.UtcNow;
var today = DateTime.Today; 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 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 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(), 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)); if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
var today = DateTime.Today; 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) var items = allInvoices.Where(i => i.Customer != null && i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff && i.Status != InvoiceStatus.Paid)
.Select(i => .Select(i =>
{ {
@@ -1758,7 +1773,8 @@ public class ReportsController : Controller
/// </summary> /// </summary>
public async Task<IActionResult> PowderConsumption(int months = 6) 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) 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 }) .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() }) .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) public async Task<IActionResult> InventoryTurnover(int months = 6)
{ {
var daysInPeriod = months * 30.0; var daysInPeriod = months * 30.0;
var inventory = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList(); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allTx = (await _unitOfWork.InventoryTransactions.GetAllAsync(false, t => t.InventoryItem)).ToList(); 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 items = inventory.Where(i => i.IsActive).Select(i =>
{ {
var iTx = allTx.Where(t => t.InventoryItemId == i.Id).ToList(); var iTx = allTx.Where(t => t.InventoryItemId == i.Id).ToList();
@@ -1835,8 +1852,9 @@ public class ReportsController : Controller
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var today = DateTime.Today; var today = DateTime.Today;
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Load invoices for AR data // 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 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(); 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 companyName = await GetCompanyNameAsync();
var today = DateTime.Today; var today = DateTime.Today;
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Open AR invoices // 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) .Where(i => i.BalanceDue > 0 && i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff && i.Status != InvoiceStatus.Paid)
.ToList(); .ToList();
// Compute avg days to pay per customer from paid invoices // 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) .Where(i => i.Status == InvoiceStatus.Paid && i.InvoiceDate != default)
.ToList(); .ToList();
var avgDaysByCustomer = paidInvoices var avgDaysByCustomer = paidInvoices
@@ -2137,7 +2156,8 @@ public class ReportsController : Controller
var companyName = await GetCompanyNameAsync(); var companyName = await GetCompanyNameAsync();
var today = DateTime.Today; 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 => var activeInvoices = allInvoices.Where(i =>
i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.Voided &&
i.Status != InvoiceStatus.WrittenOff).ToList(); i.Status != InvoiceStatus.WrittenOff).ToList();
@@ -2256,8 +2276,9 @@ public class ReportsController : Controller
var companyName = await GetCompanyNameAsync(); var companyName = await GetCompanyNameAsync();
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var startOfYear = new DateTime(now.Year, 1, 1); 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) .Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff)
.ToList(); .ToList();
@@ -15,11 +15,13 @@ namespace PowderCoating.Web.Controllers;
public class SmsConsentAuditController : Controller public class SmsConsentAuditController : Controller
{ {
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly ITenantContext _tenantContext;
private readonly ILogger<SmsConsentAuditController> _logger; private readonly ILogger<SmsConsentAuditController> _logger;
public SmsConsentAuditController(IUnitOfWork unitOfWork, ILogger<SmsConsentAuditController> logger) public SmsConsentAuditController(IUnitOfWork unitOfWork, ITenantContext tenantContext, ILogger<SmsConsentAuditController> logger)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_tenantContext = tenantContext;
_logger = logger; _logger = logger;
} }
@@ -30,7 +32,8 @@ public class SmsConsentAuditController : Controller
{ {
try 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)) if (!string.IsNullOrWhiteSpace(search))
{ {
@@ -98,7 +101,8 @@ public class SmsConsentAuditController : Controller
{ {
try 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) .OrderBy(c => c.CompanyName ?? c.ContactLastName ?? c.ContactFirstName)
.ToList(); .ToList();
@@ -32,7 +32,8 @@ public class TaxRatesController : Controller
[HttpGet] [HttpGet]
public async Task<IActionResult> Index() 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()); return View(rates.OrderBy(r => r.Name).ToList());
} }
@@ -87,7 +87,8 @@ public class ToolsController : Controller
[HttpGet] [HttpGet]
public async Task<IActionResult> GetImportAccounts() 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 var revenue = allAccounts
.Where(a => a.AccountType == AccountType.Revenue && a.IsActive) .Where(a => a.AccountType == AccountType.Revenue && a.IsActive)
@@ -123,7 +124,8 @@ public class ToolsController : Controller
/// </summary> /// </summary>
private async Task PopulateImportAccountDropdownsAsync() 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 var revenueAccounts = allAccounts
.Where(a => a.AccountType == AccountType.Revenue && a.IsActive) .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 // Validate account IDs belong to this company — stale page load can produce IDs
// that were valid before a data reset but no longer exist. // 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(); .Select(a => a.Id).ToHashSet();
if (revenueAccountId.HasValue && !validAccountIds.Contains(revenueAccountId.Value)) if (revenueAccountId.HasValue && !validAccountIds.Contains(revenueAccountId.Value))
revenueAccountId = null; revenueAccountId = null;
@@ -1167,7 +1169,7 @@ public class ToolsController : Controller
// Validate account IDs belong to this company — stale page load can produce IDs // Validate account IDs belong to this company — stale page load can produce IDs
// that were valid before a data reset but no longer exist. // 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(); .Select(a => a.Id).ToHashSet();
if (inventoryAccountId.HasValue && !validAccountIds.Contains(inventoryAccountId.Value)) if (inventoryAccountId.HasValue && !validAccountIds.Contains(inventoryAccountId.Value))
inventoryAccountId = null; 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)) using (var archive = new System.IO.Compression.ZipArchive(memoryStream, System.IO.Compression.ZipArchiveMode.Create, true))
{ {
// 1. Customers // 1. Customers
var customers = await _unitOfWork.Customers.GetAllAsync(); var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId.Value);
var customersCsv = GenerateCustomersCsv(customers); var customersCsv = GenerateCustomersCsv(customers);
var customersEntry = archive.CreateEntry($"customers_{timestamp}.csv"); var customersEntry = archive.CreateEntry($"customers_{timestamp}.csv");
using (var entryStream = customersEntry.Open()) using (var entryStream = customersEntry.Open())
@@ -1949,7 +1951,7 @@ public class ToolsController : Controller
} }
// 2. Quotes // 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 quotesCsv = GenerateQuotesCsv(quotes);
var quotesEntry = archive.CreateEntry($"quotes_{timestamp}.csv"); var quotesEntry = archive.CreateEntry($"quotes_{timestamp}.csv");
using (var entryStream = quotesEntry.Open()) using (var entryStream = quotesEntry.Open())
@@ -1959,7 +1961,7 @@ public class ToolsController : Controller
} }
// 3. Jobs // 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 jobsCsv = GenerateJobsCsv(jobs);
var jobsEntry = archive.CreateEntry($"jobs_{timestamp}.csv"); var jobsEntry = archive.CreateEntry($"jobs_{timestamp}.csv");
using (var entryStream = jobsEntry.Open()) using (var entryStream = jobsEntry.Open())
@@ -1969,7 +1971,7 @@ public class ToolsController : Controller
} }
// 4. Appointments // 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); a => a.Customer, a => a.AppointmentType, a => a.AppointmentStatus);
var appointmentsCsv = GenerateAppointmentsCsv(appointments); var appointmentsCsv = GenerateAppointmentsCsv(appointments);
var appointmentsEntry = archive.CreateEntry($"appointments_{timestamp}.csv"); var appointmentsEntry = archive.CreateEntry($"appointments_{timestamp}.csv");
@@ -1980,9 +1982,9 @@ public class ToolsController : Controller
} }
// 5. Catalog // 5. Catalog
var catalogCategories = await _unitOfWork.CatalogCategories.GetAllAsync(); var catalogCategories = await _unitOfWork.CatalogCategories.FindAsync(cc => cc.CompanyId == companyId.Value);
var catalogCategoryPaths = BuildCategoryPathMap(catalogCategories); 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 catalogCsv = GenerateCatalogCsv(catalog, catalogCategoryPaths);
var catalogEntry = archive.CreateEntry($"catalog_{timestamp}.csv"); var catalogEntry = archive.CreateEntry($"catalog_{timestamp}.csv");
using (var entryStream = catalogEntry.Open()) using (var entryStream = catalogEntry.Open())
@@ -1992,7 +1994,7 @@ public class ToolsController : Controller
} }
// 6. Inventory // 6. Inventory
var inventory = await _unitOfWork.InventoryItems.GetAllAsync(); var inventory = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId.Value);
var inventoryCsv = GenerateInventoryCsv(inventory); var inventoryCsv = GenerateInventoryCsv(inventory);
var inventoryEntry = archive.CreateEntry($"inventory_{timestamp}.csv"); var inventoryEntry = archive.CreateEntry($"inventory_{timestamp}.csv");
using (var entryStream = inventoryEntry.Open()) using (var entryStream = inventoryEntry.Open())
@@ -2002,7 +2004,7 @@ public class ToolsController : Controller
} }
// 7. Equipment // 7. Equipment
var equipment = await _unitOfWork.Equipment.GetAllAsync(); var equipment = await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId.Value);
var equipmentCsv = GenerateEquipmentCsv(equipment); var equipmentCsv = GenerateEquipmentCsv(equipment);
var equipmentEntry = archive.CreateEntry($"equipment_{timestamp}.csv"); var equipmentEntry = archive.CreateEntry($"equipment_{timestamp}.csv");
using (var entryStream = equipmentEntry.Open()) using (var entryStream = equipmentEntry.Open())
@@ -2012,7 +2014,7 @@ public class ToolsController : Controller
} }
// 8. Maintenance // 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 maintenanceCsv = GenerateMaintenanceCsv(maintenance);
var maintenanceEntry = archive.CreateEntry($"maintenance_{timestamp}.csv"); var maintenanceEntry = archive.CreateEntry($"maintenance_{timestamp}.csv");
using (var entryStream = maintenanceEntry.Open()) using (var entryStream = maintenanceEntry.Open())
@@ -2022,7 +2024,7 @@ public class ToolsController : Controller
} }
// 9. Vendors // 9. Vendors
var vendors = await _unitOfWork.Vendors.GetAllAsync(); var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId.Value);
var vendorsCsv = GenerateVendorsCsv(vendors); var vendorsCsv = GenerateVendorsCsv(vendors);
var vendorsEntry = archive.CreateEntry($"vendors_{timestamp}.csv"); var vendorsEntry = archive.CreateEntry($"vendors_{timestamp}.csv");
using (var entryStream = vendorsEntry.Open()) using (var entryStream = vendorsEntry.Open())
@@ -2032,7 +2034,7 @@ public class ToolsController : Controller
} }
// 10. Prep Services // 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 prepServicesCsv = GeneratePrepServicesCsv(prepServices);
var prepServicesEntry = archive.CreateEntry($"prep_services_{timestamp}.csv"); var prepServicesEntry = archive.CreateEntry($"prep_services_{timestamp}.csv");
using (var entryStream = prepServicesEntry.Open()) using (var entryStream = prepServicesEntry.Open())
@@ -2042,7 +2044,7 @@ public class ToolsController : Controller
} }
// 11. Invoices // 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 invoicesCsv = GenerateInvoicesCsv(invoices);
var invoicesEntry = archive.CreateEntry($"invoices_{timestamp}.csv"); var invoicesEntry = archive.CreateEntry($"invoices_{timestamp}.csv");
using (var entryStream = invoicesEntry.Open()) using (var entryStream = invoicesEntry.Open())
@@ -2052,7 +2054,7 @@ public class ToolsController : Controller
} }
// 12. Chart of Accounts // 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 accountsCsv = GenerateChartOfAccountsCsv(accounts);
var accountsEntry = archive.CreateEntry($"chart_of_accounts_{timestamp}.csv"); var accountsEntry = archive.CreateEntry($"chart_of_accounts_{timestamp}.csv");
using (var entryStream = accountsEntry.Open()) using (var entryStream = accountsEntry.Open())
@@ -2062,7 +2064,7 @@ public class ToolsController : Controller
} }
// 13. Expenses // 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 expensesCsv = GenerateExpensesCsv(expenses);
var expensesEntry = archive.CreateEntry($"expenses_{timestamp}.csv"); var expensesEntry = archive.CreateEntry($"expenses_{timestamp}.csv");
using (var entryStream = expensesEntry.Open()) using (var entryStream = expensesEntry.Open())
@@ -2072,7 +2074,7 @@ public class ToolsController : Controller
} }
// 14. Payments // 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 paymentsCsv = GeneratePaymentsCsv(payments);
var paymentsEntry = archive.CreateEntry($"payments_{timestamp}.csv"); var paymentsEntry = archive.CreateEntry($"payments_{timestamp}.csv");
using (var entryStream = paymentsEntry.Open()) using (var entryStream = paymentsEntry.Open())
@@ -2258,9 +2260,9 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index)); 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 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 csv = GenerateCatalogCsv(catalogItems, catalogCategoryPaths);
var fileName = $"catalog_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; var fileName = $"catalog_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -2326,7 +2328,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index)); 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 csv = GenerateEquipmentCsv(equipment);
var fileName = $"equipment_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; var fileName = $"equipment_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -2407,13 +2409,13 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index)); return RedirectToAction(nameof(Index));
} }
// Load all lookup tables // Load all lookup tables — scoped to this company
var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync(); var jobStatuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == companyId.Value);
var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync(); var jobPriorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId.Value);
var quoteStatuses = await _unitOfWork.QuoteStatusLookups.GetAllAsync(); var quoteStatuses = await _unitOfWork.QuoteStatusLookups.FindAsync(s => s.CompanyId == companyId.Value);
var inventoryCategories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync(); var inventoryCategories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId.Value);
var appointmentStatuses = await _unitOfWork.AppointmentStatusLookups.GetAllAsync(); var appointmentStatuses = await _unitOfWork.AppointmentStatusLookups.FindAsync(s => s.CompanyId == companyId.Value);
var appointmentTypes = await _unitOfWork.AppointmentTypeLookups.GetAllAsync(); var appointmentTypes = await _unitOfWork.AppointmentTypeLookups.FindAsync(t => t.CompanyId == companyId.Value);
var csv = GenerateCompanySettingsCsv(company, jobStatuses, jobPriorities, var csv = GenerateCompanySettingsCsv(company, jobStatuses, jobPriorities,
quoteStatuses, inventoryCategories, appointmentStatuses, appointmentTypes); quoteStatuses, inventoryCategories, appointmentStatuses, appointmentTypes);
@@ -4092,7 +4094,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index)); 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 csv = GeneratePrepServicesCsv(prepServices);
var fileName = $"prep_services_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; var fileName = $"prep_services_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -4124,7 +4126,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index)); 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 csv = GenerateVendorsCsv(vendors);
var fileName = $"vendors_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; var fileName = $"vendors_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -4156,7 +4158,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index)); 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 csv = GenerateChartOfAccountsCsv(accounts);
var fileName = $"chart_of_accounts_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; var fileName = $"chart_of_accounts_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -52,12 +52,13 @@
<div class="card-body p-0"> <div class="card-body p-0">
@if (!Model.Items.Any()) @if (!Model.Items.Any())
{ {
var isCustomerListFiltered = !string.IsNullOrEmpty(ViewBag.SearchTerm as string);
<div class="text-center py-5"> <div class="text-center py-5">
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i> <i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
<h5 class="mt-3 text-muted">No customers found</h5> <h5 class="mt-3 text-muted">No customers found</h5>
<p class="text-muted mb-4">Get started by adding your first customer</p> <p class="text-muted mb-4">@(isCustomerListFiltered ? "No customers match your search." : "Get started by adding your first customer.")</p>
<a asp-action="Create" class="btn btn-primary"> <a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>Add Your First Customer <i class="bi bi-plus-circle me-2"></i>@(isCustomerListFiltered ? "Add Customer" : "Add Your First Customer")
</a> </a>
</div> </div>
} }
@@ -187,9 +188,9 @@
<div class="text-center py-5"> <div class="text-center py-5">
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i> <i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
<h5 class="mt-3 text-muted">No customers found</h5> <h5 class="mt-3 text-muted">No customers found</h5>
<p class="text-muted mb-4">Get started by adding your first customer</p> <p class="text-muted mb-4">@(isCustomerListFiltered ? "No customers match your search." : "Get started by adding your first customer.")</p>
<a asp-action="Create" class="btn btn-primary"> <a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>Add Your First Customer <i class="bi bi-plus-circle me-2"></i>@(isCustomerListFiltered ? "Add Customer" : "Add Your First Customer")
</a> </a>
</div> </div>
} }
@@ -61,12 +61,13 @@
<div class="card-body p-0"> <div class="card-body p-0">
@if (!Model.Items.Any()) @if (!Model.Items.Any())
{ {
var isEquipmentListFiltered = !string.IsNullOrEmpty(ViewBag.SearchTerm as string) || ViewBag.StatusFilter != null;
<div class="text-center py-5"> <div class="text-center py-5">
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i> <i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
<h5 class="mt-3 text-muted">No equipment found</h5> <h5 class="mt-3 text-muted">No equipment found</h5>
<p class="text-muted mb-4">Get started by adding your first equipment</p> <p class="text-muted mb-4">@(isEquipmentListFiltered ? "No equipment matches your search." : "Get started by adding your first equipment.")</p>
<a asp-action="Create" class="btn btn-primary"> <a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>Add Your First Equipment <i class="bi bi-plus-circle me-2"></i>@(isEquipmentListFiltered ? "Add Equipment" : "Add Your First Equipment")
</a> </a>
</div> </div>
} }
@@ -191,12 +191,13 @@
<div class="card-body p-0"> <div class="card-body p-0">
@if (!Model.Items.Any()) @if (!Model.Items.Any())
{ {
var isInventoryFiltered = !string.IsNullOrEmpty(ViewBag.SearchTerm as string) || !string.IsNullOrEmpty(ViewBag.Category as string) || lowStockOnly;
<div class="text-center py-5"> <div class="text-center py-5">
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i> <i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
<h5 class="mt-3 text-muted">No inventory items found</h5> <h5 class="mt-3 text-muted">No inventory items found</h5>
<p class="text-muted mb-4">Get started by adding your first inventory item</p> <p class="text-muted mb-4">@(isInventoryFiltered ? "No items match your current filters." : "Get started by adding your first inventory item.")</p>
<a asp-action="Create" class="btn btn-primary"> <a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>Add Your First Item <i class="bi bi-plus-circle me-2"></i>@(isInventoryFiltered ? "Add Item" : "Add Your First Item")
</a> </a>
</div> </div>
} }
@@ -1105,9 +1105,22 @@
<div class="modal-body"> <div class="modal-body">
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold">Inventory Item <span class="text-danger">*</span></label> <label class="form-label fw-semibold">Inventory Item <span class="text-danger">*</span></label>
<select id="lmInventoryItem" class="form-select"> <div class="position-relative">
<option value="">-- Select item --</option> <div class="input-group">
</select> <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 id="lmItemBalance" class="form-text text-muted d-none"></div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
@@ -3149,10 +3162,11 @@
<script> <script>
(function () { (function () {
const inventoryItems = @Html.Raw(ViewBag.InventoryItemsForModal ?? "[]"); const inventoryItems = @Html.Raw(ViewBag.InventoryItemsForModal ?? "[]");
const jobPowderIds = @Html.Raw(ViewBag.JobPowderIds ?? "[]");
const jobId = @Model.Id; const jobId = @Model.Id;
const logUrl = '@Url.Action("LogMaterial", "Jobs")'; const logUrl = '@Url.Action("LogMaterial", "Jobs")';
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? ''; const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
window.__logMaterial = { inventoryItems, jobId, logUrl, token }; window.__logMaterial = { inventoryItems, jobPowderIds, jobId, logUrl, token };
})(); })();
</script> </script>
+186 -51
View File
@@ -4,62 +4,173 @@
*/ */
(function () { (function () {
let _items = []; let _items = [];
let _jobPowderIds = new Set();
let _modal = null; let _modal = null;
function init() { // ── Combobox state ────────────────────────────────────────────────────────
const cfg = window.__logMaterial; let _selectedItemId = 0;
if (!cfg) return;
_items = cfg.inventoryItems || []; function lmComboInput() {
_modal = new bootstrap.Modal(document.getElementById('logMaterialModal')); const q = document.getElementById('lmItemSearch')?.value?.toLowerCase() || '';
lmComboRender(q);
const sel = document.getElementById('lmInventoryItem'); lmComboShow();
_items.forEach(function (item) { _selectedItemId = 0;
const opt = document.createElement('option'); document.getElementById('lmItemBalance').classList.add('d-none');
opt.value = item.id;
opt.textContent = item.name + (item.unitOfMeasure ? ' (' + item.unitOfMeasure + ')' : '');
opt.dataset.qty = item.quantityOnHand;
opt.dataset.uom = item.unitOfMeasure || '';
sel.appendChild(opt);
});
sel.addEventListener('change', lmOnItemChange);
document.getElementById('lmQuantity').addEventListener('input', lmOnQtyInput);
}
function lmOnItemChange() {
const sel = document.getElementById('lmInventoryItem');
const opt = sel.options[sel.selectedIndex];
const balDiv = document.getElementById('lmItemBalance');
if (sel.value && opt) {
const qty = parseFloat(opt.dataset.qty) || 0;
const uom = opt.dataset.uom;
balDiv.textContent = 'Current stock: ' + qty.toFixed(2) + (uom ? ' ' + uom : '');
balDiv.classList.remove('d-none');
} else {
balDiv.classList.add('d-none');
}
lmOnQtyInput(); 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() { function lmOnQtyInput() {
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value; const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
if (method !== 'remaining') { if (method !== 'remaining') {
document.getElementById('lmComputedUsed').classList.add('d-none'); document.getElementById('lmComputedUsed').classList.add('d-none');
return; return;
} }
const sel = document.getElementById('lmInventoryItem'); if (!_selectedItemId) {
const opt = sel.options[sel.selectedIndex]; 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 remaining = parseFloat(document.getElementById('lmQuantity').value) || 0;
const onHand = parseFloat(opt?.dataset.qty) || 0;
const used = onHand - remaining; const used = onHand - remaining;
const computedDiv = document.getElementById('lmComputedUsed'); const computedDiv = document.getElementById('lmComputedUsed');
if (sel.value) { computedDiv.textContent = 'Usage = ' + onHand.toFixed(2) + ' ' + remaining.toFixed(2) + ' = ' + used.toFixed(2) + (item?.unitOfMeasure ? ' ' + item.unitOfMeasure : '');
computedDiv.textContent = 'Usage = ' + onHand.toFixed(2) + ' ' + remaining.toFixed(2) + ' = ' + used.toFixed(2) + ' ' + (opt?.dataset.uom || ''); computedDiv.classList.remove('d-none');
computedDiv.classList.remove('d-none');
} else {
computedDiv.classList.add('d-none');
}
} }
window.lmUpdateQuantityLabel = function () { window.lmUpdateQuantityLabel = function () {
@@ -70,9 +181,11 @@
lmOnQtyInput(); lmOnQtyInput();
}; };
// ── Modal open / save ─────────────────────────────────────────────────────
window.openLogMaterialModal = function () { window.openLogMaterialModal = function () {
// Reset form _selectedItemId = 0;
document.getElementById('lmInventoryItem').value = ''; document.getElementById('lmItemSearch').value = '';
document.getElementById('lmItemBalance').classList.add('d-none'); document.getElementById('lmItemBalance').classList.add('d-none');
document.getElementById('lmQuantity').value = ''; document.getElementById('lmQuantity').value = '';
document.getElementById('lmComputedUsed').classList.add('d-none'); document.getElementById('lmComputedUsed').classList.add('d-none');
@@ -82,14 +195,12 @@
document.getElementById('lmSaveBtn').disabled = false; document.getElementById('lmSaveBtn').disabled = false;
document.getElementById('lmMethodUsed').checked = true; document.getElementById('lmMethodUsed').checked = true;
window.lmUpdateQuantityLabel(); window.lmUpdateQuantityLabel();
lmComboClose();
if (_modal) _modal.show(); if (_modal) _modal.show();
}; };
window.lmSave = async function () { window.lmSave = async function () {
const cfg = window.__logMaterial; const cfg = window.__logMaterial;
const itemId = parseInt(document.getElementById('lmInventoryItem').value) || 0;
const qtyInput = parseFloat(document.getElementById('lmQuantity').value) || 0;
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
const alertEl = document.getElementById('lmAlert'); const alertEl = document.getElementById('lmAlert');
function showError(msg) { function showError(msg) {
@@ -98,13 +209,16 @@
alertEl.classList.remove('d-none'); alertEl.classList.remove('d-none');
} }
if (!itemId) { showError('Please select an inventory item.'); return; } 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; } if (qtyInput <= 0) { showError('Please enter a quantity greater than zero.'); return; }
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
let quantityUsed = qtyInput; let quantityUsed = qtyInput;
if (method === 'remaining') { if (method === 'remaining') {
const sel = document.getElementById('lmInventoryItem'); const item = _items.find(it => it.id === _selectedItemId);
const onHand = parseFloat(sel.options[sel.selectedIndex]?.dataset.qty) || 0; const onHand = item ? (parseFloat(item.quantityOnHand) || 0) : 0;
quantityUsed = onHand - qtyInput; quantityUsed = onHand - qtyInput;
if (quantityUsed <= 0) { if (quantityUsed <= 0) {
showError('Remaining quantity cannot be equal to or greater than the current stock (' + onHand.toFixed(2) + ').'); showError('Remaining quantity cannot be equal to or greater than the current stock (' + onHand.toFixed(2) + ').');
@@ -125,7 +239,7 @@
}, },
body: JSON.stringify({ body: JSON.stringify({
jobId: cfg.jobId, jobId: cfg.jobId,
inventoryItemId: itemId, inventoryItemId: _selectedItemId,
quantityUsed: quantityUsed, quantityUsed: quantityUsed,
transactionType: document.getElementById('lmTransactionType').value, transactionType: document.getElementById('lmTransactionType').value,
notes: document.getElementById('lmNotes').value.trim() || null notes: document.getElementById('lmNotes').value.trim() || null
@@ -134,7 +248,6 @@
const data = await resp.json(); const data = await resp.json();
if (data.success) { if (data.success) {
if (_modal) _modal.hide(); if (_modal) _modal.hide();
// Reload page so the materials table refreshes
window.location.reload(); window.location.reload();
} else { } else {
showError(data.message || 'An error occurred.'); showError(data.message || 'An error occurred.');
@@ -146,5 +259,27 @@
} }
}; };
// ── 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); 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!);
}
}