Compare commits

...

21 Commits

Author SHA1 Message Date
spouliot 31c5746e5b Guard ShopWorker drops in AddAppointmentReminderSentAt migration with IF EXISTS
Prod and dev databases diverged on whether ShopWorker tables and indexes
exist, causing unconditional DROP statements to fail on prod. Replaced
all individual DropForeignKey/DropTable/DropIndex/DropColumn calls with
a single SQL block using IF EXISTS guards so the migration runs safely
regardless of DB state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:43:30 -04:00
spouliot 3f9ac27afa Merge dev into master for release v2026.05.19 2026-05-19 16:37:26 -04:00
spouliot df504674e9 Add oven/batch settings to job create and edit forms
CreateJobDto and UpdateJobDto now carry OvenCostId, OvenBatches, and
OvenCycleMinutes. The Create POST sets these on the new Job entity and
passes them to the pricing engine; the Edit GET populates them from the
existing job so the form reflects saved values, and the Edit POST writes
them back before repricing.

Both Jobs/Create.cshtml and Jobs/Edit.cshtml now include an Oven & Batch
Settings card (matching the quote form) with oven selector, batch count,
and cycle time inputs. The wizard init block now passes the selected
OvenCostId instead of null so live auto-pricing reflects the oven cost.

ViewBag.DefaultOvenCycleMinutes added to PopulateCreateEditWizardViewBagsAsync
so the placeholder in both views shows the company default.

Also fixed: NoExtraLayerCharge was missing from the Edit GET coat DTO
mapping (would have caused the flag to reset to false on next edit).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:27:54 -04:00
spouliot 07796b05c8 Clear ReminderSentAt when appointment is rescheduled
Edit POST now detects if ScheduledStartTime changed (via previousStart
comparison after AutoMapper merge) and nulls ReminderSentAt so the
background service will fire the reminder again at the new time.
Calendar drag-drop (UpdateEventTime) always clears ReminderSentAt since
rescheduling is its only purpose.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:03:58 -04:00
spouliot 2bf8871892 Fix NoExtraLayerCharge persistence, appointment reminders, coat notes display, scroll restoration, and invoice Send dead-button
- Appointment reminders: add AppointmentReminderBackgroundService (60s poll), ReminderSentAt
  dedup stamp, NotifyAppointmentReminderAsync sends both customer email and creator staff email;
  AppointmentReminderStaff notification type + default template added; DateTime.Now used instead
  of UtcNow to match locally-stored ScheduledStartTime; ToLocalTime() double-conversion removed

- NoExtraLayerCharge not persisted: flag existed on CreateQuoteItemCoatDto and was used by
  pricing engine but never written to JobItemCoat/QuoteItemCoat entities — every edit reset it
  to false and re-applied the extra layer charge; added column to both entities (migration
  AddNoExtraLayerChargeToCoats), both read DTOs, all 3 JobItemAssemblyService overloads,
  JobItemCoatSeed inner class, and existingItemsData JSON in all 5 wizard views; fixed JS
  template path that hard-coded noExtraLayerCharge: false

- Coat notes not visible: notes were rendered in desktop job details but missing from the wizard
  item card summary and the mobile card view; both fixed

- Scroll position lost on item save: sessionStorage save/restore added to item-wizard.js owner
  form submit handler; path-keyed so cross-page navigation does not restore stale position;
  requestAnimationFrame used for reliable mobile scroll restoration

- Invoice Send dead button: #sendChannelModal was gated inside @if (isDraft) but the button
  targeting it fires for Sent/Overdue invoices too when customer has both email and SMS; modal
  moved outside the Draft guard

- InitialCreate migration added for fresh database installs; Baseline migration guarded with
  IF OBJECT_ID check so it no-ops on fresh DBs; Razor scoping bug fixed in Customers/Index.cshtml

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 10:45:40 -04:00
60 changed files with 40687 additions and 589 deletions
@@ -137,6 +137,13 @@ public class CreateJobDto
[Display(Name = "Oven")] [Display(Name = "Oven")]
public int? OvenCostId { get; set; } public int? OvenCostId { get; set; }
[Display(Name = "Batches")]
[Range(1, 999)]
public int OvenBatches { get; set; } = 1;
[Display(Name = "Cycle Time (min)")]
public int? OvenCycleMinutes { get; set; }
[Required(ErrorMessage = "Description is required")] [Required(ErrorMessage = "Description is required")]
[StringLength(2000, ErrorMessage = "Description cannot exceed 2000 characters")] [StringLength(2000, ErrorMessage = "Description cannot exceed 2000 characters")]
[Display(Name = "Description")] [Display(Name = "Description")]
@@ -208,6 +215,16 @@ public class UpdateJobDto
[Display(Name = "Assigned Worker")] [Display(Name = "Assigned Worker")]
public string? AssignedUserId { get; set; } public string? AssignedUserId { get; set; }
[Display(Name = "Oven")]
public int? OvenCostId { get; set; }
[Display(Name = "Batches")]
[Range(1, 999)]
public int OvenBatches { get; set; } = 1;
[Display(Name = "Cycle Time (min)")]
public int? OvenCycleMinutes { get; set; }
[Required(ErrorMessage = "Description is required")] [Required(ErrorMessage = "Description is required")]
[StringLength(2000, ErrorMessage = "Description cannot exceed 2000 characters")] [StringLength(2000, ErrorMessage = "Description cannot exceed 2000 characters")]
[Display(Name = "Description")] [Display(Name = "Description")]
@@ -381,6 +398,7 @@ public class JobItemCoatDto
public decimal? PowderCostPerLb { get; set; } public decimal? PowderCostPerLb { get; set; }
public decimal? PowderToOrder { get; set; } public decimal? PowderToOrder { get; set; }
public decimal? ActualPowderUsedLbs { get; set; } // Filled during job completion public decimal? ActualPowderUsedLbs { get; set; } // Filled during job completion
public bool NoExtraLayerCharge { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
} }
@@ -389,7 +407,7 @@ public class CompleteJobDto
{ {
public int JobId { get; set; } public int JobId { get; set; }
public decimal? ActualTimeSpentHours { get; set; } public decimal? ActualTimeSpentHours { get; set; }
public List<JobItemCoatUsageDto> CoatUsages { get; set; } = new(); public List<JobPowderUsageDto> PowderUsages { get; set; } = new();
public bool SendEmailToCustomer { get; set; } = false; public bool SendEmailToCustomer { get; set; } = false;
} }
@@ -400,10 +418,10 @@ public class SendJobSmsRequest
public string Message { get; set; } = string.Empty; public string Message { get; set; } = string.Empty;
} }
// DTO for tracking actual powder usage per coat // DTO for tracking actual powder usage per inventory item (color) for the whole job
public class JobItemCoatUsageDto public class JobPowderUsageDto
{ {
public int JobItemCoatId { get; set; } public int InventoryItemId { get; set; }
public decimal? ActualPowderUsedLbs { get; set; } public decimal? ActualPowderUsedLbs { get; set; }
} }
@@ -801,6 +801,7 @@ public class QuoteItemCoatDto
public decimal CoatMaterialCost { get; set; } public decimal CoatMaterialCost { get; set; }
public decimal CoatLaborCost { get; set; } public decimal CoatLaborCost { get; set; }
public decimal CoatTotalCost { get; set; } public decimal CoatTotalCost { get; set; }
public bool NoExtraLayerCharge { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
} }
@@ -91,4 +91,11 @@ public interface INotificationService
/// Alert company staff when a Stripe chargeback (dispute) is opened on an invoice payment. /// Alert company staff when a Stripe chargeback (dispute) is opened on an invoice payment.
/// </summary> /// </summary>
Task NotifyChargebackAlertAsync(Invoice invoice, string disputeId, decimal amount, string reason); Task NotifyChargebackAlertAsync(Invoice invoice, string disputeId, decimal amount, string reason);
/// <summary>
/// Sends an appointment reminder email to the linked customer (if opted in) and writes a
/// notification log row. Called by <see cref="PowderCoating.Web.BackgroundServices.AppointmentReminderBackgroundService"/>
/// when the reminder window opens. In-app bell notification is handled by the caller.
/// </summary>
Task NotifyAppointmentReminderAsync(Appointment appointment);
} }
@@ -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);
@@ -62,7 +85,8 @@ public class JobItemAssemblyService : IJobItemAssemblyService
TransferEfficiency = c.TransferEfficiency, TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb, PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency), PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
Notes = c.Notes Notes = c.Notes,
NoExtraLayerCharge = c.NoExtraLayerCharge
}, },
jobItemId, jobItemId,
companyId, companyId,
@@ -70,6 +94,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 +114,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 +164,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);
@@ -151,7 +193,8 @@ public class JobItemAssemblyService : IJobItemAssemblyService
TransferEfficiency = c.TransferEfficiency, TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb, PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency), PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
Notes = c.Notes Notes = c.Notes,
NoExtraLayerCharge = c.NoExtraLayerCharge
}, },
jobItemId, jobItemId,
companyId, companyId,
@@ -160,6 +203,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 +221,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 +266,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);
@@ -234,7 +291,8 @@ public class JobItemAssemblyService : IJobItemAssemblyService
TransferEfficiency = c.TransferEfficiency, TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb, PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = c.PowderToOrder, PowderToOrder = c.PowderToOrder,
Notes = c.Notes Notes = c.Notes,
NoExtraLayerCharge = c.NoExtraLayerCharge
}, },
jobItemId, jobItemId,
companyId, companyId,
@@ -242,6 +300,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 +318,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 +358,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
@@ -310,11 +378,17 @@ public class JobItemAssemblyService : IJobItemAssemblyService
PowderCostPerLb = seed.PowderCostPerLb, PowderCostPerLb = seed.PowderCostPerLb,
PowderToOrder = seed.PowderToOrder, PowderToOrder = seed.PowderToOrder,
Notes = seed.Notes, Notes = seed.Notes,
NoExtraLayerCharge = seed.NoExtraLayerCharge,
CompanyId = companyId, CompanyId = companyId,
CreatedAt = createdAtUtc CreatedAt = createdAtUtc
}; };
} }
/// <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 +404,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 +429,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 +447,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 +482,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;
@@ -399,8 +497,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
public decimal? PowderCostPerLb { get; init; } public decimal? PowderCostPerLb { get; init; }
public decimal? PowderToOrder { get; init; } public decimal? PowderToOrder { get; init; }
public string? Notes { get; init; } public string? Notes { get; init; }
public bool NoExtraLayerCharge { 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
@@ -95,6 +95,12 @@ public class Appointment : BaseEntity
/// </summary> /// </summary>
public int ReminderMinutesBefore { get; set; } = 30; public int ReminderMinutesBefore { get; set; } = 30;
/// <summary>
/// UTC timestamp when the reminder was dispatched. Null means it hasn't fired yet.
/// The background service uses this as a deduplication guard to prevent double-sending.
/// </summary>
public DateTime? ReminderSentAt { get; set; }
// Navigation Properties // Navigation Properties
public virtual Customer? Customer { get; set; } public virtual Customer? Customer { get; set; }
public virtual Job? Job { get; set; } public virtual Job? Job { get; set; }
@@ -42,6 +42,13 @@ public class JobItemCoat : BaseEntity
public string? PowderReceivedByUserId { get; set; } public string? PowderReceivedByUserId { get; set; }
public decimal? PowderReceivedLbs { get; set; } public decimal? PowderReceivedLbs { get; set; }
// Pricing flags
/// <summary>
/// When true, the additional layer labor charge is not applied for this coat even if it is
/// not the first coat in the sequence. Used for clear coats, sealers, etc.
/// </summary>
public bool NoExtraLayerCharge { get; set; }
// Notes // Notes
public string? Notes { get; set; } public string? Notes { get; set; }
@@ -33,6 +33,13 @@ public class QuoteItemCoat : BaseEntity
public decimal CoatLaborCost { get; set; } public decimal CoatLaborCost { get; set; }
public decimal CoatTotalCost { get; set; } public decimal CoatTotalCost { get; set; }
// Pricing flags
/// <summary>
/// When true, the additional layer labor charge is not applied for this coat even if it is
/// not the first coat in the sequence. Used for clear coats, sealers, etc.
/// </summary>
public bool NoExtraLayerCharge { get; set; }
// Notes // Notes
public string? Notes { get; set; } public string? Notes { get; set; }
@@ -20,5 +20,7 @@ public enum NotificationType
SmsInboundStop = 12, SmsInboundStop = 12,
SmsInboundHelp = 13, SmsInboundHelp = 13,
AdminEmail = 14, AdminEmail = 14,
SmsInboundStart = 15 SmsInboundStart = 15,
AppointmentReminder = 17,
AppointmentReminderStaff = 18
} }
@@ -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;
} }
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -11,26 +11,20 @@ namespace PowderCoating.Infrastructure.Migrations
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.UpdateData( // These UpdateData calls were generated from an existing live database.
table: "PricingTiers", // On a fresh install the PricingTiers table and its seed rows may not exist yet
keyColumn: "Id", // (seeding is manual via Platform Management → Seed Data), so guard each update.
keyValue: 1, migrationBuilder.Sql(@"
column: "CreatedAt", IF OBJECT_ID(N'[PricingTiers]', N'U') IS NOT NULL
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7851)); BEGIN
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 1)
migrationBuilder.UpdateData( UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377851Z' WHERE [Id] = 1;
table: "PricingTiers", IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 2)
keyColumn: "Id", UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377856Z' WHERE [Id] = 2;
keyValue: 2, IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 3)
column: "CreatedAt", UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377858Z' WHERE [Id] = 3;
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7856)); END
");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7858));
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -0,0 +1,217 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddAppointmentReminderSentAt : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Use IF EXISTS guards for all ShopWorker drops — prod and dev diverged on whether
// these objects exist, so unconditional drops would fail on whichever DB is missing them.
migrationBuilder.Sql(@"
IF EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = 'FK_Jobs_ShopWorkers_ShopWorkerId')
ALTER TABLE [Jobs] DROP CONSTRAINT [FK_Jobs_ShopWorkers_ShopWorkerId];
IF EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = 'FK_JobTimeEntries_ShopWorkers_ShopWorkerId')
ALTER TABLE [JobTimeEntries] DROP CONSTRAINT [FK_JobTimeEntries_ShopWorkers_ShopWorkerId];
IF EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = 'FK_MaintenanceRecords_ShopWorkers_ShopWorkerId')
ALTER TABLE [MaintenanceRecords] DROP CONSTRAINT [FK_MaintenanceRecords_ShopWorkers_ShopWorkerId];
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ShopWorkerRoleCosts')
DROP TABLE [ShopWorkerRoleCosts];
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ShopWorkers')
DROP TABLE [ShopWorkers];
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_MaintenanceRecords_ShopWorkerId' AND object_id = OBJECT_ID('MaintenanceRecords'))
DROP INDEX [IX_MaintenanceRecords_ShopWorkerId] ON [MaintenanceRecords];
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_JobTimeEntries_ShopWorkerId' AND object_id = OBJECT_ID('JobTimeEntries'))
DROP INDEX [IX_JobTimeEntries_ShopWorkerId] ON [JobTimeEntries];
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Jobs_ShopWorkerId' AND object_id = OBJECT_ID('Jobs'))
DROP INDEX [IX_Jobs_ShopWorkerId] ON [Jobs];
IF EXISTS (SELECT 1 FROM sys.columns WHERE name = 'ShopWorkerId' AND object_id = OBJECT_ID('MaintenanceRecords'))
ALTER TABLE [MaintenanceRecords] DROP COLUMN [ShopWorkerId];
IF EXISTS (SELECT 1 FROM sys.columns WHERE name = 'ShopWorkerId' AND object_id = OBJECT_ID('JobTimeEntries'))
ALTER TABLE [JobTimeEntries] DROP COLUMN [ShopWorkerId];
IF EXISTS (SELECT 1 FROM sys.columns WHERE name = 'ShopWorkerId' AND object_id = OBJECT_ID('Jobs'))
ALTER TABLE [Jobs] DROP COLUMN [ShopWorkerId];
");
migrationBuilder.AddColumn<DateTime>(
name: "ReminderSentAt",
table: "Appointments",
type: "datetime2",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 15, 12, 57, 355, DateTimeKind.Utc).AddTicks(2970));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 15, 12, 57, 355, DateTimeKind.Utc).AddTicks(2976));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 15, 12, 57, 355, DateTimeKind.Utc).AddTicks(2977));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ReminderSentAt",
table: "Appointments");
migrationBuilder.AddColumn<int>(
name: "ShopWorkerId",
table: "MaintenanceRecords",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ShopWorkerId",
table: "JobTimeEntries",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ShopWorkerId",
table: "Jobs",
type: "int",
nullable: true);
migrationBuilder.CreateTable(
name: "ShopWorkerRoleCosts",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
HourlyRate = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
Role = table.Column<int>(type: "int", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ShopWorkerRoleCosts", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ShopWorkers",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
Email = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
Phone = table.Column<string>(type: "nvarchar(max)", nullable: true),
Role = table.Column<int>(type: "int", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ShopWorkers", x => x.Id);
table.ForeignKey(
name: "FK_ShopWorkers_Companies_CompanyId",
column: x => x.CompanyId,
principalTable: "Companies",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3131));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3137));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3138));
migrationBuilder.CreateIndex(
name: "IX_MaintenanceRecords_ShopWorkerId",
table: "MaintenanceRecords",
column: "ShopWorkerId");
migrationBuilder.CreateIndex(
name: "IX_JobTimeEntries_ShopWorkerId",
table: "JobTimeEntries",
column: "ShopWorkerId");
migrationBuilder.CreateIndex(
name: "IX_Jobs_ShopWorkerId",
table: "Jobs",
column: "ShopWorkerId");
migrationBuilder.CreateIndex(
name: "IX_ShopWorkerRoleCosts_CompanyId_Role",
table: "ShopWorkerRoleCosts",
columns: new[] { "CompanyId", "Role" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ShopWorkers_CompanyId",
table: "ShopWorkers",
column: "CompanyId");
migrationBuilder.AddForeignKey(
name: "FK_Jobs_ShopWorkers_ShopWorkerId",
table: "Jobs",
column: "ShopWorkerId",
principalTable: "ShopWorkers",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_JobTimeEntries_ShopWorkers_ShopWorkerId",
table: "JobTimeEntries",
column: "ShopWorkerId",
principalTable: "ShopWorkers",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_MaintenanceRecords_ShopWorkers_ShopWorkerId",
table: "MaintenanceRecords",
column: "ShopWorkerId",
principalTable: "ShopWorkers",
principalColumn: "Id");
}
}
}
@@ -0,0 +1,83 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddNoExtraLayerChargeToCoats : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "NoExtraLayerCharge",
table: "QuoteItemCoats",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "NoExtraLayerCharge",
table: "JobItemCoats",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5186));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5190));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5191));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "NoExtraLayerCharge",
table: "QuoteItemCoats");
migrationBuilder.DropColumn(
name: "NoExtraLayerCharge",
table: "JobItemCoats");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 15, 44, 18, 742, DateTimeKind.Utc).AddTicks(3960));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 15, 44, 18, 742, DateTimeKind.Utc).AddTicks(3966));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 15, 44, 18, 742, DateTimeKind.Utc).AddTicks(3967));
}
}
}
@@ -716,6 +716,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int>("ReminderMinutesBefore") b.Property<int>("ReminderMinutesBefore")
.HasColumnType("int"); .HasColumnType("int");
b.Property<DateTime?>("ReminderSentAt")
.HasColumnType("datetime2");
b.Property<DateTime>("ScheduledEndTime") b.Property<DateTime>("ScheduledEndTime")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
@@ -4252,9 +4255,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<decimal>("ShopSuppliesPercent") b.Property<decimal>("ShopSuppliesPercent")
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
b.Property<int?>("ShopWorkerId")
.HasColumnType("int");
b.Property<string>("SpecialInstructions") b.Property<string>("SpecialInstructions")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -4296,8 +4296,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("ScheduledDate"); b.HasIndex("ScheduledDate");
b.HasIndex("ShopWorkerId");
b.HasIndex("CompanyId", "CustomerId") b.HasIndex("CompanyId", "CustomerId")
.HasDatabaseName("IX_Jobs_CompanyId_CustomerId"); .HasDatabaseName("IX_Jobs_CompanyId_CustomerId");
@@ -4620,6 +4618,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int>("JobItemId") b.Property<int>("JobItemId")
.HasColumnType("int"); .HasColumnType("int");
b.Property<bool>("NoExtraLayerCharge")
.HasColumnType("bit");
b.Property<string>("Notes") b.Property<string>("Notes")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -5439,9 +5440,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("Notes") b.Property<string>("Notes")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<int?>("ShopWorkerId")
.HasColumnType("int");
b.Property<string>("Stage") b.Property<string>("Stage")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -5464,8 +5462,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("JobId"); b.HasIndex("JobId");
b.HasIndex("ShopWorkerId");
b.ToTable("JobTimeEntries"); b.ToTable("JobTimeEntries");
}); });
@@ -5789,9 +5785,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<DateTime>("ScheduledDate") b.Property<DateTime>("ScheduledDate")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
b.Property<int?>("ShopWorkerId")
.HasColumnType("int");
b.Property<int>("Status") b.Property<int>("Status")
.HasColumnType("int"); .HasColumnType("int");
@@ -5822,8 +5815,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("ScheduledDate"); b.HasIndex("ScheduledDate");
b.HasIndex("ShopWorkerId");
b.HasIndex("Status"); b.HasIndex("Status");
b.HasIndex("CompanyId", "ScheduledDate") b.HasIndex("CompanyId", "ScheduledDate")
@@ -6720,7 +6711,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 1, Id = 1,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3131), CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5186),
Description = "Standard pricing for regular customers", Description = "Standard pricing for regular customers",
DiscountPercent = 0m, DiscountPercent = 0m,
IsActive = true, IsActive = true,
@@ -6731,7 +6722,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 2, Id = 2,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3137), CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5190),
Description = "5% discount for preferred customers", Description = "5% discount for preferred customers",
DiscountPercent = 5m, DiscountPercent = 5m,
IsActive = true, IsActive = true,
@@ -6742,7 +6733,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 3, Id = 3,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3138), CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5191),
Description = "10% discount for premium customers", Description = "10% discount for premium customers",
DiscountPercent = 10m, DiscountPercent = 10m,
IsActive = true, IsActive = true,
@@ -7417,6 +7408,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("IsDeleted") b.Property<bool>("IsDeleted")
.HasColumnType("bit"); .HasColumnType("bit");
b.Property<bool>("NoExtraLayerCharge")
.HasColumnType("bit");
b.Property<string>("Notes") b.Property<string>("Notes")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -8019,111 +8013,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("ReworkRecords"); b.ToTable("ReworkRecords");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorker", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("Email")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<string>("Phone")
.HasColumnType("nvarchar(max)");
b.Property<int>("Role")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("CompanyId");
b.ToTable("ShopWorkers");
});
modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorkerRoleCost", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<decimal>("HourlyRate")
.HasColumnType("decimal(18,2)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<int>("Role")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("CompanyId", "Role")
.IsUnique()
.HasDatabaseName("IX_ShopWorkerRoleCosts_CompanyId_Role");
b.ToTable("ShopWorkerRoleCosts");
});
modelBuilder.Entity("PowderCoating.Core.Entities.StripeWebhookEvent", b => modelBuilder.Entity("PowderCoating.Core.Entities.StripeWebhookEvent", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@@ -9541,10 +9430,6 @@ namespace PowderCoating.Infrastructure.Migrations
.HasForeignKey("PowderCoating.Core.Entities.Job", "QuoteId") .HasForeignKey("PowderCoating.Core.Entities.Job", "QuoteId")
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
b.HasOne("PowderCoating.Core.Entities.ShopWorker", null)
.WithMany("AssignedJobs")
.HasForeignKey("ShopWorkerId");
b.Navigation("AssignedUser"); b.Navigation("AssignedUser");
b.Navigation("Customer"); b.Navigation("Customer");
@@ -9847,13 +9732,7 @@ namespace PowderCoating.Infrastructure.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("PowderCoating.Core.Entities.ShopWorker", "Worker")
.WithMany("TimeEntries")
.HasForeignKey("ShopWorkerId");
b.Navigation("Job"); b.Navigation("Job");
b.Navigation("Worker");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.JournalEntry", b => modelBuilder.Entity("PowderCoating.Core.Entities.JournalEntry", b =>
@@ -9924,10 +9803,6 @@ namespace PowderCoating.Infrastructure.Migrations
.WithMany() .WithMany()
.HasForeignKey("RecurrenceParentId"); .HasForeignKey("RecurrenceParentId");
b.HasOne("PowderCoating.Core.Entities.ShopWorker", null)
.WithMany("AssignedMaintenanceTasks")
.HasForeignKey("ShopWorkerId");
b.Navigation("AssignedUser"); b.Navigation("AssignedUser");
b.Navigation("Equipment"); b.Navigation("Equipment");
@@ -10411,15 +10286,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("ReworkJob"); b.Navigation("ReworkJob");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorker", b =>
{
b.HasOne("PowderCoating.Core.Entities.Company", null)
.WithMany("ShopWorkers")
.HasForeignKey("CompanyId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("PowderCoating.Core.Entities.Vendor", b => modelBuilder.Entity("PowderCoating.Core.Entities.Vendor", b =>
{ {
b.HasOne("PowderCoating.Core.Entities.Company", null) b.HasOne("PowderCoating.Core.Entities.Company", null)
@@ -10582,8 +10448,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("Quotes"); b.Navigation("Quotes");
b.Navigation("ShopWorkers");
b.Navigation("Users"); b.Navigation("Users");
b.Navigation("Vendors"); b.Navigation("Vendors");
@@ -10749,15 +10613,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("Quotes"); b.Navigation("Quotes");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorker", b =>
{
b.Navigation("AssignedJobs");
b.Navigation("AssignedMaintenanceTasks");
b.Navigation("TimeEntries");
});
modelBuilder.Entity("PowderCoating.Core.Entities.Vendor", b => modelBuilder.Entity("PowderCoating.Core.Entities.Vendor", b =>
{ {
b.Navigation("BillPayments"); b.Navigation("BillPayments");
@@ -1152,6 +1152,156 @@ public class NotificationService : INotificationService
}; };
} }
/// <summary>
/// Sends appointment reminder emails when an appointment's reminder window opens.
/// Two emails are dispatched independently:
/// <list type="bullet">
/// <item>Customer email — sent when a customer is linked, has an email address, and has
/// email notifications enabled (<see cref="Customer.NotifyByEmail"/>).</item>
/// <item>Staff email — sent to <see cref="BaseEntity.CreatedBy"/> (the user who created
/// the appointment). This fires regardless of whether a customer is linked.</item>
/// </list>
/// Called exclusively by
/// <see cref="PowderCoating.Web.BackgroundServices.AppointmentReminderBackgroundService"/>
/// after it stamps <c>ReminderSentAt</c> — the caller owns deduplication.
/// </summary>
public async Task NotifyAppointmentReminderAsync(Appointment appointment)
{
try
{
var (companyName, company) = await GetCompanyAsync(appointment.CompanyId);
var (replyToEmail, replyToName) = await GetEmailFromAsync(appointment.CompanyId);
var baseUrl = await GetBaseUrlAsync();
var locationLine = !string.IsNullOrWhiteSpace(appointment.Location)
? $"<br/><strong>Location:</strong> {WebUtility.HtmlEncode(appointment.Location)}"
: string.Empty;
var appointmentDate = appointment.ScheduledStartTime.ToString("dddd, MMMM d, yyyy");
var appointmentTime = appointment.IsAllDay
? "All Day"
: appointment.ScheduledStartTime.ToString("h:mm tt");
var defaultSubject = $"Appointment Reminder — {appointment.Title} on {appointment.ScheduledStartTime:MMMM d, yyyy}";
// ── Customer email ────────────────────────────────────────────────
if (appointment.CustomerId != null)
{
var customer = appointment.Customer
?? await _context.Customers.FindAsync(appointment.CustomerId.Value);
if (customer != null)
{
var customerName = GetCustomerDisplayName(customer);
var reminderEmails = ParseEmailList(customer.Email);
if (!customer.NotifyByEmail || reminderEmails.Count == 0)
{
if (reminderEmails.Count > 0)
{
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.AppointmentReminder,
customerName, string.Join(", ", reminderEmails), appointment.CompanyId,
customerId: customer.Id));
}
}
else
{
var customerValues = new Dictionary<string, string>
{
["companyName"] = companyName,
["customerName"] = customerName,
["appointmentTitle"] = appointment.Title,
["appointmentDate"] = appointmentDate,
["appointmentTime"] = appointmentTime,
["locationLine"] = locationLine
};
var (custSubject, custHtml) = await GetRenderedEmailAsync(
appointment.CompanyId, NotificationType.AppointmentReminder, customerValues, defaultSubject);
var custFullHtml = AppendUnsubscribeFooterHtml(custHtml, customer.UnsubscribeToken, company, baseUrl);
var custPlainText = StripHtml(custFullHtml);
var (custOk, custErr, custLog) = await SendToEmailListAsync(
customer.Email, customerName, custSubject, custPlainText, custFullHtml,
replyToEmail: replyToEmail, replyToName: replyToName);
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Email,
NotificationType = NotificationType.AppointmentReminder,
Status = custOk ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = custLog,
Subject = custSubject,
Message = custPlainText,
ErrorMessage = custErr,
SentAt = DateTime.UtcNow,
CustomerId = customer.Id,
CompanyId = appointment.CompanyId
});
}
}
}
// ── Staff email ───────────────────────────────────────────────────
// Send to whoever created the appointment so they get an out-of-app reminder.
if (!string.IsNullOrWhiteSpace(appointment.CreatedBy))
{
// Look up the user's display name from Identity if available.
var staffUser = await _context.Users
.FirstOrDefaultAsync(u => u.Email == appointment.CreatedBy);
var staffName = !string.IsNullOrWhiteSpace(staffUser?.FullName)
? staffUser.FullName
: appointment.CreatedBy;
// Include a customer line only when a customer is linked.
var customerLine = appointment.Customer != null
? $"<br/><strong>Customer:</strong> {WebUtility.HtmlEncode(GetCustomerDisplayName(appointment.Customer))}"
: string.Empty;
var staffValues = new Dictionary<string, string>
{
["companyName"] = companyName,
["staffName"] = staffName,
["appointmentTitle"] = appointment.Title,
["appointmentDate"] = appointmentDate,
["appointmentTime"] = appointmentTime,
["customerLine"] = customerLine,
["locationLine"] = locationLine
};
var staffDefaultSubject = $"[Reminder] {appointment.Title} — {appointment.ScheduledStartTime:MMMM d, yyyy 'at' h:mm tt}";
var (staffSubject, staffHtml) = await GetRenderedEmailAsync(
appointment.CompanyId, NotificationType.AppointmentReminderStaff, staffValues, staffDefaultSubject);
var staffPlainText = StripHtml(staffHtml);
var (staffOk, staffErr, staffLog) = await SendToEmailListAsync(
appointment.CreatedBy, staffName, staffSubject, staffPlainText, staffHtml,
replyToEmail: replyToEmail, replyToName: replyToName);
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Email,
NotificationType = NotificationType.AppointmentReminderStaff,
Status = staffOk ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = staffName,
Recipient = staffLog,
Subject = staffSubject,
Message = staffPlainText,
ErrorMessage = staffErr,
SentAt = DateTime.UtcNow,
CompanyId = appointment.CompanyId
});
}
}
catch (Exception ex)
{
_logger.LogError(ex, "NotifyAppointmentReminderAsync failed for appointment {AppointmentId}", appointment.Id);
}
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Fallback default templates (used when company has no DB template) // Fallback default templates (used when company has no DB template)
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -1217,6 +1367,14 @@ public class NotificationService : INotificationService
"Payment Reminder — Invoice {{invoiceNumber}} ({{daysOverdue}} days overdue)", "Payment Reminder — Invoice {{invoiceNumber}} ({{daysOverdue}} days overdue)",
"<p>Dear {{customerName}},</p><p>This is a friendly reminder that invoice <strong>{{invoiceNumber}}</strong> for <strong>{{invoiceTotal}}</strong> was due on <strong>{{dueDate}}</strong> and is now <strong>{{daysOverdue}} days overdue</strong>.</p><p>Outstanding balance: <strong>{{balanceDue}}</strong></p><p>Please arrange payment at your earliest convenience. If you have already sent payment, please disregard this notice.</p><p>Thank you for your business with {{companyName}}.</p>" "<p>Dear {{customerName}},</p><p>This is a friendly reminder that invoice <strong>{{invoiceNumber}}</strong> for <strong>{{invoiceTotal}}</strong> was due on <strong>{{dueDate}}</strong> and is now <strong>{{daysOverdue}} days overdue</strong>.</p><p>Outstanding balance: <strong>{{balanceDue}}</strong></p><p>Please arrange payment at your earliest convenience. If you have already sent payment, please disregard this notice.</p><p>Thank you for your business with {{companyName}}.</p>"
), ),
[(NotificationType.AppointmentReminder, NotificationChannel.Email)] = (
"Appointment Reminder — {{appointmentTitle}} on {{appointmentDate}}",
"<p>Dear {{customerName}},</p><p>This is a reminder that you have an upcoming appointment with <strong>{{companyName}}</strong>.</p><p><strong>Appointment:</strong> {{appointmentTitle}}<br/><strong>Date &amp; Time:</strong> {{appointmentDate}} at {{appointmentTime}}{{locationLine}}</p><p>If you have any questions or need to reschedule, please contact us at your earliest convenience.</p><p>Thank you for choosing {{companyName}}.</p>"
),
[(NotificationType.AppointmentReminderStaff, NotificationChannel.Email)] = (
"[Reminder] {{appointmentTitle}} — {{appointmentDate}}",
"<p>Hi {{staffName}},</p><p>This is a reminder that you have an upcoming appointment.</p><p><strong>Appointment:</strong> {{appointmentTitle}}<br/><strong>Date &amp; Time:</strong> {{appointmentDate}} at {{appointmentTime}}{{customerLine}}{{locationLine}}</p><p>&#8212; {{companyName}}</p>"
),
}; };
public static (string? Subject, string Body)? Get(NotificationType type, NotificationChannel channel) public static (string? Subject, string Body)? Get(NotificationType type, NotificationChannel channel)
@@ -0,0 +1,162 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.Interfaces;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Web.BackgroundServices;
/// <summary>
/// Polls every 60 seconds for appointments whose reminder window has opened and dispatches
/// an email to the linked customer plus an in-app bell notification to company staff.
///
/// Deduplication strategy: after selecting candidates the service immediately stamps
/// <c>ReminderSentAt</c> on each appointment and saves before calling the notification
/// methods. This prevents a second loop iteration from re-sending if notifications are slow
/// or the application restarts mid-batch. A 24-hour lookback window caps the query so that
/// appointments that slipped through (e.g., server downtime) are silently skipped rather
/// than sending a stale reminder.
/// </summary>
public class AppointmentReminderBackgroundService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<AppointmentReminderBackgroundService> _logger;
private static readonly TimeSpan PollingInterval = TimeSpan.FromMinutes(1);
/// <summary>
/// Appointments whose scheduled start is more than this far in the past are ignored even
/// if their reminder was never sent (server was down, etc.). We do not want to blast a
/// customer with a "your appointment is in 30 minutes" email hours after it was due.
/// </summary>
private static readonly TimeSpan MaxLookback = TimeSpan.FromHours(24);
public AppointmentReminderBackgroundService(
IServiceScopeFactory scopeFactory,
ILogger<AppointmentReminderBackgroundService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
/// <summary>
/// Long-running loop that wakes every <see cref="PollingInterval"/> (60 s) and calls
/// <see cref="RunAsync"/>. Uses <see cref="Task.Delay"/> with the cancellation token so
/// the service shuts down promptly when the application stops.
/// </summary>
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("AppointmentReminderBackgroundService started.");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(PollingInterval, stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
if (stoppingToken.IsCancellationRequested) break;
await RunAsync(stoppingToken);
}
_logger.LogInformation("AppointmentReminderBackgroundService stopped.");
}
/// <summary>
/// One poll iteration: find all appointments whose reminder window has opened, stamp them,
/// then dispatch email + in-app notifications. A fresh DI scope is created per poll so that
/// the DbContext change tracker is clean each time.
/// </summary>
private async Task RunAsync(CancellationToken ct)
{
try
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var notificationService = scope.ServiceProvider.GetRequiredService<INotificationService>();
var inAppService = scope.ServiceProvider.GetRequiredService<IInAppNotificationService>();
// ScheduledStartTime is stored as server-local time (no UTC conversion on form submit),
// so compare against DateTime.Now rather than UtcNow to avoid a 4-hour EDT offset.
var now = DateTime.Now;
var lookback = now - MaxLookback;
// Find appointments where:
// - Reminder is enabled and has not been sent yet
// - The reminder window has opened: ScheduledStartTime - ReminderMinutesBefore <= now
// - The appointment hasn't been sitting unprocessed for more than MaxLookback
// - The appointment status is not terminal (not cancelled, completed, no-show, etc.)
// IgnoreQueryFilters bypasses the tenant filter — no HTTP context in a background service.
var candidates = await db.Appointments
.IgnoreQueryFilters()
.Include(a => a.Customer)
.Include(a => a.AppointmentStatus)
.Where(a =>
!a.IsDeleted &&
a.IsReminderEnabled &&
a.ReminderSentAt == null &&
a.ScheduledStartTime > lookback &&
EF.Functions.DateDiffMinute(now, a.ScheduledStartTime) <= a.ReminderMinutesBefore &&
!a.AppointmentStatus.IsTerminalStatus)
.ToListAsync(ct);
if (candidates.Count == 0) return;
_logger.LogInformation(
"AppointmentReminderBackgroundService: {Count} appointment reminder(s) to dispatch.",
candidates.Count);
// Stamp ReminderSentAt before sending — prevents a restart from re-sending.
var stampedAt = now;
foreach (var appt in candidates)
appt.ReminderSentAt = stampedAt;
await db.SaveChangesAsync(ct);
// Now send notifications. Failures here don't roll back the stamp because we'd
// rather skip one reminder than spam a customer on every restart.
foreach (var appt in candidates)
{
if (ct.IsCancellationRequested) break;
try
{
// Email to linked customer (no-ops internally if customer has opted out)
await notificationService.NotifyAppointmentReminderAsync(appt);
// In-app bell notification for company staff
var when = appt.IsAllDay
? appt.ScheduledStartTime.ToString("MMMM d, yyyy")
: appt.ScheduledStartTime.ToString("MMMM d, yyyy 'at' h:mm tt");
await inAppService.CreateAsync(
companyId: appt.CompanyId,
title: $"Appointment Reminder: {appt.Title}",
message: $"{appt.AppointmentNumber} is scheduled for {when}.",
notificationType: "AppointmentReminder",
link: $"/Appointments/Details/{appt.Id}",
customerId: appt.CustomerId);
_logger.LogInformation(
"Reminder dispatched for appointment {AppointmentNumber} (id {Id}, company {CompanyId}).",
appt.AppointmentNumber, appt.Id, appt.CompanyId);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to dispatch reminder for appointment {AppointmentId}.", appt.Id);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "AppointmentReminderBackgroundService poll failed.");
}
}
}
@@ -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();
@@ -381,9 +381,15 @@ public class AppointmentsController : Controller
return View(dto); return View(dto);
} }
// Map changes // Map changes — capture old start before overwrite so we can detect a reschedule.
var previousStart = appointment.ScheduledStartTime;
_mapper.Map(dto, appointment); _mapper.Map(dto, appointment);
// If the appointment was rescheduled, clear the reminder stamp so the background
// service will fire again at the new time.
if (appointment.ScheduledStartTime != previousStart)
appointment.ReminderSentAt = null;
// Update // Update
await _unitOfWork.Appointments.UpdateAsync(appointment); await _unitOfWork.Appointments.UpdateAsync(appointment);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
@@ -486,9 +492,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 +510,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 +550,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);
@@ -718,6 +731,8 @@ public class AppointmentsController : Controller
var duration = appointment.ScheduledEndTime - appointment.ScheduledStartTime; var duration = appointment.ScheduledEndTime - appointment.ScheduledStartTime;
appointment.ScheduledStartTime = start; appointment.ScheduledStartTime = start;
appointment.ScheduledEndTime = end; appointment.ScheduledEndTime = end;
// Drag-drop always changes the time — reset so the reminder fires at the new time.
appointment.ReminderSentAt = null;
await _unitOfWork.Appointments.UpdateAsync(appointment); await _unitOfWork.Appointments.UpdateAsync(appointment);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
@@ -746,13 +761,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 +909,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 +923,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)
@@ -340,13 +340,14 @@ public class InvoicesController : Controller
var costs = await _unitOfWork.CompanyOperatingCosts var costs = await _unitOfWork.CompanyOperatingCosts
.FirstOrDefaultAsync(c => c.CompanyId == currentUser.CompanyId && !c.IsDeleted); .FirstOrDefaultAsync(c => c.CompanyId == currentUser.CompanyId && !c.IsDeleted);
var defaultTerms = prefs?.DefaultPaymentTerms ?? "Net 30";
var dto = new CreateInvoiceDto var dto = new CreateInvoiceDto
{ {
PreparedById = currentUser.Id, PreparedById = currentUser.Id,
InvoiceDate = DateTime.Today, InvoiceDate = DateTime.Today,
DueDate = DateTime.Today.AddDays(prefs?.DefaultTurnaroundDays ?? 30), DueDate = PaymentTermsParser.CalculateDueDate(defaultTerms, DateTime.Today),
TaxPercent = costs?.TaxPercent ?? 0, TaxPercent = costs?.TaxPercent ?? 0,
Terms = prefs?.DefaultPaymentTerms ?? "Net 30" Terms = defaultTerms
}; };
if (jobId.HasValue) if (jobId.HasValue)
@@ -378,6 +379,13 @@ public class InvoicesController : Controller
var defaultRevenueAccount = await _unitOfWork.Accounts var defaultRevenueAccount = await _unitOfWork.Accounts
.FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.IsActive); .FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.IsActive);
// Deserialize the job's pricing snapshot up front — it is authoritative for discount,
// tax, and fees for both quote-based and direct jobs, because it is recalculated on
// every save and reflects any edits made after quote conversion.
QuotePricingBreakdownDto? jobBreakdown = null;
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
jobBreakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
// If the job came from a quote, load it so we can use the agreed pricing. // If the job came from a quote, load it so we can use the agreed pricing.
// The quote stores the approved total including oven batch cost and shop supplies — // The quote stores the approved total including oven batch cost and shop supplies —
// these are quote-level charges that are NOT stored on individual job items. // these are quote-level charges that are NOT stored on individual job items.
@@ -461,17 +469,15 @@ public class InvoicesController : Controller
}); });
} }
// Use the quote's agreed tax rate and discount — not current company defaults // Use the quote's agreed tax rate and discount — these represent the customer-approved
dto.TaxPercent = sourceQuote.TaxPercent; // price and must not be recomputed from the job's current state.
dto.TaxPercent = sourceQuote.TaxPercent;
dto.DiscountAmount = sourceQuote.DiscountAmount; dto.DiscountAmount = sourceQuote.DiscountAmount;
} }
else if (hadJobItems) else if (hadJobItems)
{ {
// Direct job — no source quote. Read all charges from the pricing snapshot so the // Direct job — no source quote. Read all charges from the pricing snapshot so the
// invoice always matches the total shown on the job's Pricing Summary card. // invoice always matches the total shown on the job's Pricing Summary card.
QuotePricingBreakdownDto? jobBreakdown = null;
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
jobBreakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
if (job.OvenBatchCost > 0.01m) if (job.OvenBatchCost > 0.01m)
{ {
@@ -529,6 +535,22 @@ public class InvoicesController : Controller
RevenueAccountId = defaultRevenueAccount?.Id RevenueAccountId = defaultRevenueAccount?.Id
}); });
} }
dto.DiscountAmount = jobBreakdown?.DiscountAmount ?? 0;
}
// Inherit payment terms from the source quote or the customer — more specific than
// the company-wide default set in the outer DTO. Quote terms take priority because
// they represent the agreed price; customer terms are next best for direct jobs.
var inheritedTerms = sourceQuote?.Terms ?? job.Customer?.PaymentTerms;
if (!string.IsNullOrWhiteSpace(inheritedTerms))
{
dto.Terms = inheritedTerms;
dto.DueDate = PaymentTermsParser.CalculateDueDate(inheritedTerms, DateTime.Today)
?? dto.DueDate;
var (discPct, discDays) = PaymentTermsParser.ParseEarlyPaymentDiscount(inheritedTerms);
dto.EarlyPaymentDiscountPercent = discPct;
dto.EarlyPaymentDiscountDays = discDays;
} }
// Override tax to 0 for tax-exempt customers, regardless of company default or quote rate // Override tax to 0 for tax-exempt customers, regardless of company default or quote rate
@@ -2191,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);
@@ -498,6 +498,23 @@ public class JobsController : Controller
.OrderByDescending(t => t.TransactionDate).ToList(); .OrderByDescending(t => t.TransactionDate).ToList();
ViewBag.MaterialsUsed = allJobTransactions; ViewBag.MaterialsUsed = allJobTransactions;
// Inventory items for the manual log-material modal
var inventoryItemsForModal = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == job.CompanyId))
.OrderBy(i => i.Name)
.Select(i => new { i.Id, i.Name, i.Manufacturer, i.UnitOfMeasure, i.QuantityOnHand })
.ToList();
var jsonOpts = new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase };
ViewBag.InventoryItemsForModal = System.Text.Json.JsonSerializer.Serialize(inventoryItemsForModal, jsonOpts);
// IDs of powders already assigned to this job's coats — shown at top of log-material dropdown
var jobPowderIds = (jobDto.Items ?? new List<PowderCoating.Application.DTOs.Job.JobItemDto>())
.SelectMany(i => i.Coats ?? new List<PowderCoating.Application.DTOs.Job.JobItemCoatDto>())
.Where(c => c.InventoryItemId.HasValue)
.Select(c => c.InventoryItemId!.Value)
.Distinct()
.ToList();
ViewBag.JobPowderIds = System.Text.Json.JsonSerializer.Serialize(jobPowderIds, jsonOpts);
// Pre-logged powder grouped by InventoryItemId (for Complete Job modal pre-fill) // Pre-logged powder grouped by InventoryItemId (for Complete Job modal pre-fill)
ViewBag.PreLoggedPowder = allJobTransactions ViewBag.PreLoggedPowder = allJobTransactions
.GroupBy(t => t.InventoryItemId) .GroupBy(t => t.InventoryItemId)
@@ -511,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
@@ -617,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;
@@ -640,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.");
@@ -828,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)
{ {
@@ -885,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)
{ {
@@ -1060,6 +1078,8 @@ public class JobsController : Controller
QuoteId = dto.QuoteId, QuoteId = dto.QuoteId,
AssignedUserId = dto.AssignedUserId, AssignedUserId = dto.AssignedUserId,
OvenCostId = dto.OvenCostId, OvenCostId = dto.OvenCostId,
OvenBatches = dto.OvenBatches > 0 ? dto.OvenBatches : 1,
OvenCycleMinutes = dto.OvenCycleMinutes,
Description = dto.Description, Description = dto.Description,
JobPriorityId = dto.JobPriorityId, JobPriorityId = dto.JobPriorityId,
JobStatusId = pendingStatus?.Id ?? 1, JobStatusId = pendingStatus?.Id ?? 1,
@@ -1131,7 +1151,7 @@ public class JobsController : Controller
var totals = await _pricingService.CalculateQuoteTotalsAsync( var totals = await _pricingService.CalculateQuoteTotalsAsync(
dto.JobItems, companyId, dto.CustomerId, dto.JobItems, companyId, dto.CustomerId,
await GetEffectiveTaxPercentAsync(dto.CustomerId, createCosts?.TaxPercent ?? 0m), await GetEffectiveTaxPercentAsync(dto.CustomerId, createCosts?.TaxPercent ?? 0m),
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, createOvenRate, job.OvenBatches, job.OvenCycleMinutes); dto.DiscountType, dto.DiscountValue, dto.IsRushJob, createOvenRate, dto.OvenBatches > 0 ? dto.OvenBatches : 1, dto.OvenCycleMinutes);
job.FinalPrice = totals.Total; job.FinalPrice = totals.Total;
job.OvenBatchCost = totals.OvenBatchCost; job.OvenBatchCost = totals.OvenBatchCost;
@@ -1199,6 +1219,9 @@ public class JobsController : Controller
CustomerId = job.CustomerId, CustomerId = job.CustomerId,
QuoteId = job.QuoteId, QuoteId = job.QuoteId,
AssignedUserId = job.AssignedUserId, AssignedUserId = job.AssignedUserId,
OvenCostId = job.OvenCostId,
OvenBatches = job.OvenBatches > 0 ? job.OvenBatches : 1,
OvenCycleMinutes = job.OvenCycleMinutes,
Description = job.Description, Description = job.Description,
JobStatusId = job.JobStatusId, JobStatusId = job.JobStatusId,
JobPriorityId = job.JobPriorityId, JobPriorityId = job.JobPriorityId,
@@ -1243,6 +1266,7 @@ public class JobsController : Controller
TransferEfficiency = c.TransferEfficiency, TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb, PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = c.PowderToOrder, PowderToOrder = c.PowderToOrder,
NoExtraLayerCharge = c.NoExtraLayerCharge,
Notes = c.Notes Notes = c.Notes
}).ToList(), }).ToList(),
PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto
@@ -1373,6 +1397,9 @@ public class JobsController : Controller
job.CustomerId = dto.CustomerId; job.CustomerId = dto.CustomerId;
job.QuoteId = dto.QuoteId; job.QuoteId = dto.QuoteId;
job.Description = dto.Description; job.Description = dto.Description;
job.OvenCostId = dto.OvenCostId;
job.OvenBatches = dto.OvenBatches > 0 ? dto.OvenBatches : 1;
job.OvenCycleMinutes = dto.OvenCycleMinutes;
await RecordStatusChangeAsync(job, dto.JobStatusId); await RecordStatusChangeAsync(job, dto.JobStatusId);
job.JobStatusId = dto.JobStatusId; job.JobStatusId = dto.JobStatusId;
job.JobPriorityId = dto.JobPriorityId; job.JobPriorityId = dto.JobPriorityId;
@@ -1599,7 +1626,7 @@ public class JobsController : Controller
var totals = await _pricingService.CalculateQuoteTotalsAsync( var totals = await _pricingService.CalculateQuoteTotalsAsync(
dto.JobItems, companyId, dto.CustomerId, dto.JobItems, companyId, dto.CustomerId,
await GetEffectiveTaxPercentAsync(dto.CustomerId, editCosts?.TaxPercent ?? 0m), await GetEffectiveTaxPercentAsync(dto.CustomerId, editCosts?.TaxPercent ?? 0m),
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, editOvenRate, job.OvenBatches, job.OvenCycleMinutes); dto.DiscountType, dto.DiscountValue, dto.IsRushJob, editOvenRate, dto.OvenBatches > 0 ? dto.OvenBatches : 1, dto.OvenCycleMinutes);
job.FinalPrice = totals.Total; job.FinalPrice = totals.Total;
job.OvenBatchCost = totals.OvenBatchCost; job.OvenBatchCost = totals.OvenBatchCost;
job.ShopSuppliesAmount = totals.ShopSuppliesAmount; job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
@@ -1792,7 +1819,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;
@@ -1800,6 +1827,7 @@ public class JobsController : Controller
ViewBag.ComplexityModeratePercent = costs?.ComplexityModeratePercent ?? 5m; ViewBag.ComplexityModeratePercent = costs?.ComplexityModeratePercent ?? 5m;
ViewBag.ComplexityComplexPercent = costs?.ComplexityComplexPercent ?? 15m; ViewBag.ComplexityComplexPercent = costs?.ComplexityComplexPercent ?? 15m;
ViewBag.ComplexityExtremePercent = costs?.ComplexityExtremePercent ?? 25m; ViewBag.ComplexityExtremePercent = costs?.ComplexityExtremePercent ?? 25m;
ViewBag.DefaultOvenCycleMinutes = costs?.DefaultOvenCycleMinutes ?? 45;
var useMetric = await _tenantContext.UseMetricSystemAsync(); var useMetric = await _tenantContext.UseMetricSystemAsync();
ViewBag.UseMetric = useMetric; ViewBag.UseMetric = useMetric;
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric); ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
@@ -1812,7 +1840,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
{ {
@@ -1823,8 +1853,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)
@@ -2206,13 +2234,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();
@@ -2648,78 +2676,80 @@ public class JobsController : Controller
.GroupBy(t => t.InventoryItemId) .GroupBy(t => t.InventoryItemId)
.ToDictionary(g => g.Key, g => Math.Abs(g.Sum(t => t.Quantity))); .ToDictionary(g => g.Key, g => Math.Abs(g.Sum(t => t.Quantity)));
// Update actual powder usage for each coat // Process powder usage submitted per inventory item (color) for the whole job.
foreach (var coatUsage in dto.CoatUsages) // Distribute entered lbs across coats sharing that InventoryItemId proportionally
// by estimated PowderToOrder so per-coat reporting stays meaningful.
// One inventory deduction per powder (net of pre-logged credit).
if (dto.PowderUsages.Any())
{ {
var jobItemCoat = await _unitOfWork.JobItemCoats.GetByIdAsync( // Load all coats for the job with their inventory items
coatUsage.JobItemCoatId, var allCoats = (await _unitOfWork.JobItemCoats.FindAsync(
false, jic => jic.JobItem != null && jic.JobItem.JobId == dto.JobId,
jic => jic.InventoryItem); false, jic => jic.InventoryItem, jic => jic.JobItem))
.ToList();
if (jobItemCoat != null) foreach (var powderUsage in dto.PowderUsages)
{ {
jobItemCoat.ActualPowderUsedLbs = coatUsage.ActualPowderUsedLbs; if (!powderUsage.ActualPowderUsedLbs.HasValue || powderUsage.ActualPowderUsedLbs.Value <= 0)
await _unitOfWork.JobItemCoats.UpdateAsync(jobItemCoat); continue;
_logger.LogInformation("Updated JobItemCoat {CoatId} with {Lbs} lbs actual powder used", var invItemId = powderUsage.InventoryItemId;
coatUsage.JobItemCoatId, coatUsage.ActualPowderUsedLbs); var totalActualLbs = powderUsage.ActualPowderUsedLbs.Value;
// Deduct powder from inventory if using stock powder // Distribute across coats using this powder proportionally by estimated lbs
if (jobItemCoat.InventoryItemId.HasValue && var coatsForPowder = allCoats.Where(c => c.InventoryItemId == invItemId).ToList();
coatUsage.ActualPowderUsedLbs.HasValue && if (coatsForPowder.Any())
coatUsage.ActualPowderUsedLbs.Value > 0)
{ {
var invItemId = jobItemCoat.InventoryItemId.Value; var totalEstimated = coatsForPowder.Sum(c => c.PowderToOrder ?? 0m);
var actualLbs = coatUsage.ActualPowderUsedLbs.Value; foreach (var coat in coatsForPowder)
// Apply available pre-logged credit so we don't double-deduct
var credit = preLoggedCredit.GetValueOrDefault(invItemId, 0m);
var deductNow = Math.Max(0m, actualLbs - credit);
// Consume credit (other coats sharing the same powder get whatever remains)
preLoggedCredit[invItemId] = Math.Max(0m, credit - actualLbs);
if (deductNow > 0)
{ {
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(invItemId); var share = totalEstimated > 0
if (inventoryItem != null) ? totalActualLbs * ((coat.PowderToOrder ?? 0m) / totalEstimated)
{ : totalActualLbs / coatsForPowder.Count;
var transaction = new InventoryTransaction coat.ActualPowderUsedLbs = Math.Round(share, 4);
{ await _unitOfWork.JobItemCoats.UpdateAsync(coat);
InventoryItemId = inventoryItem.Id,
TransactionType = InventoryTransactionType.JobUsage,
Quantity = -deductNow,
UnitCost = inventoryItem.UnitCost,
TotalCost = inventoryItem.UnitCost * deductNow,
TransactionDate = DateTime.UtcNow,
JobId = job.Id,
Reference = job.JobNumber,
Notes = $"Powder used for Job {job.JobNumber} - {jobItemCoat.CoatName} ({jobItemCoat.ColorName ?? "N/A"}) by {currentUser!.FirstName} {currentUser.LastName}",
BalanceAfter = inventoryItem.QuantityOnHand - deductNow,
CompanyId = job.CompanyId
};
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
inventoryItem.QuantityOnHand -= deductNow;
await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem);
// GL: DR COGS, CR Inventory Asset (accrual) — no-op if accounts not configured
if (inventoryItem.CogsAccountId.HasValue && inventoryItem.InventoryAccountId.HasValue)
{
var cost = deductNow * (inventoryItem.AverageCost > 0 ? inventoryItem.AverageCost : inventoryItem.UnitCost);
await _accountBalanceService.DebitAsync(inventoryItem.CogsAccountId, cost);
await _accountBalanceService.CreditAsync(inventoryItem.InventoryAccountId, cost);
}
_logger.LogInformation(
"Deducted {Lbs} lbs (net of pre-logged) of {Item} from inventory for Job {JobNumber}. New quantity: {NewQty}",
deductNow, inventoryItem.Name, job.JobNumber, inventoryItem.QuantityOnHand);
}
} }
else }
// Single inventory deduction for the whole powder, net of pre-logged credit
var credit = preLoggedCredit.GetValueOrDefault(invItemId, 0m);
var deductNow = Math.Max(0m, totalActualLbs - credit);
preLoggedCredit[invItemId] = 0m;
if (deductNow > 0)
{
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(invItemId);
if (inventoryItem != null)
{ {
inventoryItem.QuantityOnHand -= deductNow;
await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem);
var transaction = new InventoryTransaction
{
InventoryItemId = inventoryItem.Id,
TransactionType = InventoryTransactionType.JobUsage,
Quantity = -deductNow,
UnitCost = inventoryItem.UnitCost,
TotalCost = inventoryItem.UnitCost * deductNow,
TransactionDate = DateTime.UtcNow,
JobId = job.Id,
Reference = job.JobNumber,
Notes = $"Powder used for Job {job.JobNumber} by {currentUser!.FirstName} {currentUser.LastName}",
BalanceAfter = inventoryItem.QuantityOnHand,
CompanyId = job.CompanyId
};
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
if (inventoryItem.CogsAccountId.HasValue && inventoryItem.InventoryAccountId.HasValue)
{
var cost = deductNow * (inventoryItem.AverageCost > 0 ? inventoryItem.AverageCost : inventoryItem.UnitCost);
await _accountBalanceService.DebitAsync(inventoryItem.CogsAccountId, cost);
await _accountBalanceService.CreditAsync(inventoryItem.InventoryAccountId, cost);
}
_logger.LogInformation( _logger.LogInformation(
"Skipped inventory deduction for JobItemCoat {CoatId} — {Lbs} lbs already pre-logged for inventory item {InvItemId}", "Deducted {Lbs} lbs (net of pre-logged) of {Item} from inventory for Job {JobNumber}. New quantity: {NewQty}",
coatUsage.JobItemCoatId, actualLbs, invItemId); deductNow, inventoryItem.Name, job.JobNumber, inventoryItem.QuantityOnHand);
} }
} }
} }
@@ -3147,7 +3177,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)
@@ -3167,12 +3197,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)
@@ -3201,10 +3231,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();
@@ -4080,9 +4110,87 @@ public class JobsController : Controller
_logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId); _logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId);
} }
/// <summary>
/// Logs manual material usage from the job details page. Mirrors the QR scan LogUsage
/// flow in InventoryController but returns JSON so the modal can close and refresh inline.
/// Quantity is always the amount USED (caller converts from remaining if needed).
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> LogMaterial([FromBody] LogMaterialRequest req)
{
try
{
if (req.QuantityUsed <= 0)
return Json(new { success = false, message = "Quantity used must be greater than zero." });
var item = await _unitOfWork.InventoryItems.GetByIdAsync(req.InventoryItemId);
if (item == null) return Json(new { success = false, message = "Inventory item not found." });
var job = await _unitOfWork.Jobs.GetByIdAsync(req.JobId);
if (job == null) return Json(new { success = false, message = "Job not found." });
var txnType = req.TransactionType == "Waste"
? InventoryTransactionType.Waste
: InventoryTransactionType.JobUsage;
item.QuantityOnHand -= req.QuantityUsed;
item.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.InventoryItems.UpdateAsync(item);
var txn = new PowderCoating.Core.Entities.InventoryTransaction
{
InventoryItemId = item.Id,
TransactionType = txnType,
Quantity = -req.QuantityUsed,
UnitCost = item.UnitCost,
TotalCost = req.QuantityUsed * item.UnitCost,
TransactionDate = DateTime.UtcNow,
BalanceAfter = item.QuantityOnHand,
JobId = req.JobId,
Reference = $"Job {job.JobNumber}",
Notes = req.Notes?.Trim(),
CompanyId = item.CompanyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.InventoryTransactions.AddAsync(txn);
await _unitOfWork.CompleteAsync();
// GL: DR COGS, CR Inventory Asset
if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue)
{
var cost = req.QuantityUsed * (item.AverageCost > 0 ? item.AverageCost : item.UnitCost);
await _accountBalanceService.DebitAsync(item.CogsAccountId, cost);
await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
}
return Json(new
{
success = true,
message = $"Logged {req.QuantityUsed:N2} {item.UnitOfMeasure} of {item.Name}.",
newBalance = item.QuantityOnHand,
unitOfMeasure = item.UnitOfMeasure,
itemName = item.Name
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error logging material for job {JobId}", req.JobId);
return Json(new { success = false, message = "An error occurred. Please try again." });
}
}
} }
public class DeleteTimeEntryRequest { public int Id { get; set; } } public class DeleteTimeEntryRequest { public int Id { get; set; } }
public class LogMaterialRequest
{
public int JobId { get; set; }
public int InventoryItemId { get; set; }
public decimal QuantityUsed { get; set; }
public string TransactionType { get; set; } = "JobUsage";
public string? Notes { get; set; }
}
public class CreateReworkJobRequest { public int ReworkRecordId { get; set; } public string? Notes { get; set; } } public class CreateReworkJobRequest { public int ReworkRecordId { get; set; } public string? Notes { get; set; } }
public class UpdateWorkerAssignmentRequest public class UpdateWorkerAssignmentRequest
@@ -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";
@@ -302,7 +302,7 @@ public static class HelpKnowledgeBase
**Changing the customer on a job:** On the Job Details page, the Customer field is an always-visible dropdown. Select a different customer a confirmation banner appears. Click **Save** to apply or **Cancel** to revert. Use this to correct a misassigned job or to move a walk-in job to a customer's proper record after they've been added to the system. **Changing the customer on a job:** On the Job Details page, the Customer field is an always-visible dropdown. Select a different customer a confirmation banner appears. Click **Save** to apply or **Cancel** to revert. Use this to correct a misassigned job or to move a walk-in job to a customer's proper record after they've been added to the system.
**Creating an invoice from a job:** On the Job Details page, look for the Invoice section and click "Create Invoice." **Creating an invoice from a job:** On the Job Details page, look for the Invoice section and click "Create Invoice." The system pre-fills all line items, pricing, discount, tax rate, payment terms, and due date from the job and customer automatically. Review the Totals panel on the right if a discount was applied to the job it will show as a red "Discount Applied" line. Adjust anything you need, then save.
**Work Order QR Codes:** Every printed job work order includes two tiers of QR codes one for viewing the job, and a separate set for taking action on it. All QR codes require the worker to be logged in. **Work Order QR Codes:** Every printed job work order includes two tiers of QR codes one for viewing the job, and a separate set for taking action on it. All QR codes require the worker to be logged in.
@@ -314,6 +314,8 @@ public static class HelpKnowledgeBase
All QR codes require login workers must have an active account. Logging in once on their phone is sufficient for the session. All QR codes require login workers must have an active account. Logging in once on their phone is sufficient for the session.
**Logging material usage from a PC (without QR scan):** On the Job Details page, expand the Materials Used section and click **Log Material**. A modal opens where you can: select any inventory item from a dropdown (current stock level shown), choose whether to enter the amount used or the amount remaining (the system calculates usage automatically), pick a reason (Job Usage or Waste/Spillage), and add optional notes. Saves immediately and updates inventory on hand.
**Blank Work Order:** Print a pre-formatted paper work order to hand to a walk-in customer before creating a digital job record. **Blank Work Order:** Print a pre-formatted paper work order to hand to a walk-in customer before creating a digital job record.
- Access: Jobs list page printer icon button "Blank Work Order" in the top-right toolbar. Or navigate directly to /WorkOrder/Blank. - Access: Jobs list page printer icon button "Blank Work Order" in the top-right toolbar. Or navigate directly to /WorkOrder/Blank.
- The PDF opens in a new tab ready to print. It includes: company logo and address, Drop Off Date field, Client Name / Client Phone / Due Date fields, 12-row parts table (Part Description / Color / Quote), Notes box, customizable Terms & Conditions text, and a Customer Signature line. - The PDF opens in a new tab ready to print. It includes: company logo and address, Drop Off Date field, Client Name / Client Phone / Due Date fields, 12-row parts table (Part Description / Color / Quote), Notes box, customizable Terms & Conditions text, and a Customer Signature line.
+1
View File
@@ -240,6 +240,7 @@ builder.Services.AddHostedService<AuditLogRetentionBackgroundService>();
builder.Services.AddHostedService<StripeWebhookRetentionBackgroundService>(); builder.Services.AddHostedService<StripeWebhookRetentionBackgroundService>();
builder.Services.AddHostedService<SetupWizardReminderBackgroundService>(); builder.Services.AddHostedService<SetupWizardReminderBackgroundService>();
builder.Services.AddHostedService<RecurringTransactionService>(); builder.Services.AddHostedService<RecurringTransactionService>();
builder.Services.AddHostedService<AppointmentReminderBackgroundService>();
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>(); builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
builder.Services.AddScoped<IStripeService, StripeService>(); builder.Services.AddScoped<IStripeService, StripeService>();
builder.Services.AddScoped<IStripeConnectService, StripeConnectService>(); builder.Services.AddScoped<IStripeConnectService, StripeConnectService>();
@@ -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>
} }
@@ -184,12 +185,13 @@
<div class="mobile-card-view"> <div class="mobile-card-view">
@if (!Model.Items.Any()) @if (!Model.Items.Any())
{ {
var isMobileCustomerListFiltered = !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">@(isMobileCustomerListFiltered ? "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>@(isMobileCustomerListFiltered ? "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>
} }
@@ -48,8 +48,9 @@
<ol class="mb-3"> <ol class="mb-3">
<li class="mb-2">Open the job from <strong>Operations &rsaquo; Jobs</strong> and go to its Details page.</li> <li class="mb-2">Open the job from <strong>Operations &rsaquo; Jobs</strong> and go to its Details page.</li>
<li class="mb-2">Scroll to the <strong>Invoice</strong> section near the bottom of the page.</li> <li class="mb-2">Scroll to the <strong>Invoice</strong> section near the bottom of the page.</li>
<li class="mb-2">Click <strong>Create Invoice</strong>. The system generates an invoice pre-filled with all the job's line items and the final pricing.</li> <li class="mb-2">Click <strong>Create Invoice</strong>. The system pre-fills all line items, the discount, tax rate, payment terms, and due date from the job and customer automatically.</li>
<li class="mb-2">Review the invoice — check line items, totals, and the due date — then click <strong>Save Invoice</strong>.</li> <li class="mb-2">Review the <strong>Totals</strong> panel on the right &mdash; if a discount was applied to the job it shows as a red <em>Discount Applied</em> line below the subtotal. Negative line items are allowed if you need to apply a manual credit or price adjustment.</li>
<li class="mb-2">Adjust anything you need, then click <strong>Save Invoice</strong>.</li>
</ol> </ol>
<h3 class="h6 fw-semibold mt-3 mb-2">From the Invoices list (manual)</h3> <h3 class="h6 fw-semibold mt-3 mb-2">From the Invoices list (manual)</h3>
@@ -139,7 +140,7 @@
<li class="mb-2">Open the invoice from <strong>Operations &rsaquo; Invoices</strong> or from the job's Details page.</li> <li class="mb-2">Open the invoice from <strong>Operations &rsaquo; Invoices</strong> or from the job's Details page.</li>
<li class="mb-2">Click <strong>Send Invoice</strong>. The status changes from Draft to Sent.</li> <li class="mb-2">Click <strong>Send Invoice</strong>. The status changes from Draft to Sent.</li>
<li class="mb-2">If email notifications are configured, the customer receives an email with the invoice details and total due.</li> <li class="mb-2">If email notifications are configured, the customer receives an email with the invoice details and total due.</li>
<li class="mb-2">A due date is set automatically based on the customer's payment terms (e.g., Net 30 means the due date is 30 days from today).</li> <li class="mb-2">The due date and payment terms are pre-filled from the source quote (if the job came from a quote) or the customer&rsquo;s payment terms &mdash; you can always override them before saving.</li>
</ol> </ol>
<p> <p>
You can also click <strong>Download PDF</strong> on any invoice to generate a print-ready PDF You can also click <strong>Download PDF</strong> on any invoice to generate a print-ready PDF
+16 -1
View File
@@ -607,13 +607,28 @@
no anonymous bumps. no anonymous bumps.
</p> </p>
<h3 class="h6 fw-semibold mt-3 mb-2"><i class="bi bi-box-seam me-1"></i>Bottom QR Log Powder Usage</h3> <h3 class="h6 fw-semibold mt-3 mb-2"><i class="bi bi-box-seam me-1"></i>Bottom QR &mdash; Log Powder Usage</h3>
<p> <p>
One QR per unique powder on the job. Scanning opens the inventory usage log page pre-filled One QR per unique powder on the job. Scanning opens the inventory usage log page pre-filled
with that powder and the job number, so you can record actual lbs used in seconds without with that powder and the job number, so you can record actual lbs used in seconds without
navigating through the app. navigating through the app.
</p> </p>
<h3 class="h6 fw-semibold mt-3 mb-2"><i class="bi bi-droplet-half me-1"></i>Logging Material Usage from a PC</h3>
<p>
You don&rsquo;t need a phone or QR code to log material usage. On the Job Details page, expand the
<strong>Materials Used</strong> section and click <strong>Log Material</strong>. A modal opens where you can:
</p>
<ul class="mb-2">
<li>Select any inventory item from a searchable dropdown &mdash; the item&rsquo;s current stock level is shown when you pick it.</li>
<li>Choose <strong>Amount Used</strong> (enter how much was consumed) or <strong>Amount Remaining</strong> (enter what&rsquo;s left in the bag &mdash; the system calculates the usage automatically).</li>
<li>Pick a reason: <em>Job Usage</em> or <em>Waste / Spillage</em>.</li>
<li>Add optional notes.</li>
</ul>
<p>
Saving immediately reduces the item&rsquo;s stock on hand and creates an entry in the Inventory Activity ledger, exactly like a QR scan would. The QR scan icon is still available next to the button for mobile workers.
</p>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert"> <div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-lock flex-shrink-0 mt-1"></i> <i class="bi bi-lock flex-shrink-0 mt-1"></i>
<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>
} }
@@ -283,13 +283,13 @@
<td class="text-end"> <td class="text-end">
<input type="number" name="InvoiceItems[@i].UnitPrice" <input type="number" name="InvoiceItems[@i].UnitPrice"
class="form-control form-control-sm text-end unit-price-input" class="form-control form-control-sm text-end unit-price-input"
value="@item.UnitPrice.ToString("F2")" min="0" step="0.01" value="@item.UnitPrice.ToString("F2")" step="0.01"
onchange="recalcRow(this)" oninput="recalcRow(this)" /> onchange="recalcRow(this)" oninput="recalcRow(this)" />
</td> </td>
<td class="text-end"> <td class="text-end">
<input type="number" name="InvoiceItems[@i].TotalPrice" <input type="number" name="InvoiceItems[@i].TotalPrice"
class="form-control form-control-sm text-end total-price-input" class="form-control form-control-sm text-end total-price-input"
value="@item.TotalPrice.ToString("F2")" min="0" step="0.01" value="@item.TotalPrice.ToString("F2")" step="0.01"
oninput="recalcTotals()" /> oninput="recalcTotals()" />
</td> </td>
<td class="text-center"> <td class="text-center">
@@ -371,6 +371,10 @@
<input asp-for="DiscountAmount" type="number" class="form-control form-control-sm text-end" <input asp-for="DiscountAmount" type="number" class="form-control form-control-sm text-end"
min="0" step="0.01" oninput="recalcTotals()" /> min="0" step="0.01" oninput="recalcTotals()" />
</div> </div>
<div id="discountRow" class="d-flex justify-content-between mb-1 d-none">
<span class="text-muted small">Discount Applied</span>
<span id="displayDiscount" class="small text-danger">&minus;$0.00</span>
</div>
<div class="mb-2"> <div class="mb-2">
<div class="d-flex justify-content-between align-items-center mb-1"> <div class="d-flex justify-content-between align-items-center mb-1">
<label class="form-label mb-0 text-muted">Tax (%)</label> <label class="form-label mb-0 text-muted">Tax (%)</label>
@@ -725,13 +729,13 @@
<td class="text-end"> <td class="text-end">
<input type="number" name="InvoiceItems[${idx}].UnitPrice" <input type="number" name="InvoiceItems[${idx}].UnitPrice"
class="form-control form-control-sm text-end unit-price-input" class="form-control form-control-sm text-end unit-price-input"
value="${unitPrice.toFixed(2)}" min="0" step="0.01" value="${unitPrice.toFixed(2)}" step="0.01"
onchange="recalcRow(this)" oninput="recalcRow(this)" /> onchange="recalcRow(this)" oninput="recalcRow(this)" />
</td> </td>
<td class="text-end"> <td class="text-end">
<input type="number" name="InvoiceItems[${idx}].TotalPrice" <input type="number" name="InvoiceItems[${idx}].TotalPrice"
class="form-control form-control-sm text-end total-price-input" class="form-control form-control-sm text-end total-price-input"
value="${total}" min="0" step="0.01" value="${total}" step="0.01"
oninput="recalcTotals()" /> oninput="recalcTotals()" />
</td> </td>
<td class="text-center"> <td class="text-center">
@@ -797,6 +801,15 @@
const total = taxableAmount + tax; const total = taxableAmount + tax;
document.getElementById('displaySubtotal').textContent = formatCurrency(subtotal); document.getElementById('displaySubtotal').textContent = formatCurrency(subtotal);
const discountRow = document.getElementById('discountRow');
if (discountRow) {
if (discount > 0) {
document.getElementById('displayDiscount').textContent = '' + formatCurrency(discount);
discountRow.classList.remove('d-none');
} else {
discountRow.classList.add('d-none');
}
}
document.getElementById('displayTax').textContent = formatCurrency(tax); document.getElementById('displayTax').textContent = formatCurrency(tax);
document.getElementById('displayTotal').textContent = formatCurrency(total); document.getElementById('displayTotal').textContent = formatCurrency(total);
} }
@@ -869,42 +869,45 @@
</div> </div>
</div> </div>
@if (showSendModal) }
{
<!-- Send Channel Choice Modal (shown when customer has both email + SMS) --> @if (showSendModal)
<div class="modal fade" id="sendChannelModal" tabindex="-1" aria-labelledby="sendChannelModalLabel" aria-hidden="true"> {
<div class="modal-dialog modal-dialog-centered"> <!-- Send Channel Choice Modal (shown when customer has both email + SMS available).
<div class="modal-content"> Lives outside the isDraft block so it also renders for Sent/Overdue invoices
<div class="modal-header border-0 pb-0"> where the customer's email was added after an SMS-only initial send. -->
<h5 class="modal-title" id="sendChannelModalLabel"> <div class="modal fade" id="sendChannelModal" tabindex="-1" aria-labelledby="sendChannelModalLabel" aria-hidden="true">
<i class="bi bi-send text-primary me-2"></i>Send Invoice <div class="modal-dialog modal-dialog-centered">
</h5> <div class="modal-content">
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <div class="modal-header border-0 pb-0">
</div> <h5 class="modal-title" id="sendChannelModalLabel">
<div class="modal-body pt-2"> <i class="bi bi-send text-primary me-2"></i>Send Invoice
<p class="mb-3">How would you like to send <strong>@Model.InvoiceNumber</strong> to <strong>@Model.CustomerName</strong>?</p> </h5>
<div class="d-grid gap-2"> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
<button type="button" class="btn btn-outline-primary text-start" onclick="submitSendInvoice(true, false)" data-bs-dismiss="modal"> </div>
<i class="bi bi-envelope me-2"></i>Email only <div class="modal-body pt-2">
<small class="d-block text-muted ms-4">PDF attached · @Model.CustomerEmail</small> <p class="mb-3">How would you like to send <strong>@Model.InvoiceNumber</strong> to <strong>@Model.CustomerName</strong>?</p>
</button> <div class="d-grid gap-2">
<button type="button" class="btn btn-outline-primary text-start" onclick="submitSendInvoice(false, true)" data-bs-dismiss="modal"> <button type="button" class="btn btn-outline-primary text-start" onclick="submitSendInvoice(true, false)" data-bs-dismiss="modal">
<i class="bi bi-phone me-2"></i>SMS only <i class="bi bi-envelope me-2"></i>Email only
<small class="d-block text-muted ms-4">View link · @smsPhone</small> <small class="d-block text-muted ms-4">PDF attached &middot; @Model.CustomerEmail</small>
</button> </button>
<button type="button" class="btn btn-primary text-start" onclick="submitSendInvoice(true, true)" data-bs-dismiss="modal"> <button type="button" class="btn btn-outline-primary text-start" onclick="submitSendInvoice(false, true)" data-bs-dismiss="modal">
<i class="bi bi-send me-2"></i>Both Email &amp; SMS <i class="bi bi-phone me-2"></i>SMS only
<small class="d-block text-muted ms-4">PDF via email + view link via SMS</small> <small class="d-block text-muted ms-4">View link &middot; @smsPhone</small>
</button> </button>
</div> <button type="button" class="btn btn-primary text-start" onclick="submitSendInvoice(true, true)" data-bs-dismiss="modal">
</div> <i class="bi bi-send me-2"></i>Both Email &amp; SMS
<div class="modal-footer border-0 pt-0"> <small class="d-block text-muted ms-4">PDF via email + view link via SMS</small>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button> </button>
</div> </div>
</div> </div>
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</div> </div>
</div> </div>
} </div>
} }
@if (canPay) @if (canPay)
+46 -2
View File
@@ -221,6 +221,49 @@
</div> </div>
</div> </div>
<!-- Oven & Batch Settings -->
<div class="card mb-4">
<div class="card-header">
<div class="d-flex align-items-center gap-2">
<h5 class="mb-0"><i class="bi bi-thermometer-half me-2"></i>Oven &amp; Batch Settings</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Oven &amp; Batch Pricing"
data-bs-content="The oven cost is charged once per batch at the job level, not per item. Estimate how many oven loads the full job will fill &amp;mdash; for example, if you have 20 small parts and your oven fits 10, that&amp;#39;s 2 batches. Cycle time is how long each batch runs. The cost is calculated from your oven&amp;#39;s hourly rate in Settings.">
<i class="bi bi-question-circle"></i>
</a>
</div>
</div>
<div class="card-body">
<p class="text-muted small mb-3">Estimate how many oven loads the complete job will require. The oven cycle cost is added once at the job level, not per item.</p>
<div class="row g-3 align-items-end">
@if (ViewBag.OvenCosts != null && ((List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.OvenCosts).Count > 1)
{
<div class="col-md-4">
<label asp-for="OvenCostId" class="form-label fw-semibold"><i class="bi bi-thermometer-half me-1"></i>Oven</label>
<select asp-for="OvenCostId" asp-items="@((List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.OvenCosts)"
class="form-select" onchange="scheduleAutoPricing()"></select>
</div>
}
<div class="col-sm-3 col-md-2">
<label asp-for="OvenBatches" class="form-label fw-semibold">Batches</label>
<input asp-for="OvenBatches" class="form-control" type="number" min="1"
value="@(Model.OvenBatches > 0 ? Model.OvenBatches : 1)"
id="OvenBatches" onchange="scheduleAutoPricing()" />
</div>
<div class="col-sm-4 col-md-3">
<label asp-for="OvenCycleMinutes" class="form-label fw-semibold">
Cycle Time per Batch (min)
<small class="text-muted fw-normal">default: @(ViewBag.DefaultOvenCycleMinutes ?? 45)</small>
</label>
<input asp-for="OvenCycleMinutes" class="form-control" type="number" min="1"
id="OvenCycleMinutes" placeholder="@(ViewBag.DefaultOvenCycleMinutes ?? 45)"
onchange="scheduleAutoPricing()" />
</div>
</div>
</div>
</div>
<!-- Pricing Options (Rush / Discount) --> <!-- Pricing Options (Rush / Discount) -->
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"> <div class="card-header">
@@ -370,7 +413,8 @@
transferEfficiency = c.TransferEfficiency, transferEfficiency = c.TransferEfficiency,
powderCostPerLb = c.PowderCostPerLb, powderCostPerLb = c.PowderCostPerLb,
powderToOrder = c.PowderToOrder, powderToOrder = c.PowderToOrder,
notes = c.Notes notes = c.Notes,
noExtraLayerCharge = c.NoExtraLayerCharge
}), }),
prepServices = item.PrepServices.Select(ps => new { prepServices = item.PrepServices.Select(ps => new {
prepServiceId = ps.PrepServiceId, prepServiceId = ps.PrepServiceId,
@@ -387,7 +431,7 @@
"discountType": @Json.Serialize(Model.DiscountType), "discountType": @Json.Serialize(Model.DiscountType),
"discountValue": @Model.DiscountValue, "discountValue": @Model.DiscountValue,
"isRushJob": @Json.Serialize(Model.IsRushJob), "isRushJob": @Json.Serialize(Model.IsRushJob),
"ovenCostId": null, "ovenCostId": @Json.Serialize(Model.OvenCostId),
"areaUnit": @Json.Serialize((string?)ViewBag.AreaUnit), "areaUnit": @Json.Serialize((string?)ViewBag.AreaUnit),
"useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)), "useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)),
"pricingUrl": "@Url.Action("CalculatePricing", "Jobs")", "pricingUrl": "@Url.Action("CalculatePricing", "Jobs")",
@@ -653,7 +653,10 @@
<span class="mobile-card-value"> <span class="mobile-card-value">
@foreach (var coat in item.Coats.OrderBy(c => c.Sequence)) @foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
{ {
<small class="d-block">@coat.CoatName@if (!string.IsNullOrEmpty(coat.ColorName)) { <text> &ndash; @coat.ColorName</text> }</small> <small class="d-block">
@coat.CoatName@if (!string.IsNullOrEmpty(coat.ColorName)) { <text> &ndash; @coat.ColorName</text> }
@if (!string.IsNullOrEmpty(coat.Notes)) { <text><br /><span class="fst-italic text-muted ms-2">@coat.Notes</span></text> }
</small>
} }
</span> </span>
</div> </div>
@@ -1016,9 +1019,12 @@
<span class="badge bg-primary rounded-pill ms-1">@materialsUsed.Count</span> <span class="badge bg-primary rounded-pill ms-1">@materialsUsed.Count</span>
} }
<i class="bi bi-chevron-down collapse-chevron ms-1" style="transition:transform .2s;"></i> <i class="bi bi-chevron-down collapse-chevron ms-1" style="transition:transform .2s;"></i>
<span class="ms-auto"> <span class="ms-auto d-flex gap-2">
<a asp-controller="Inventory" asp-action="Scan" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation();"> <button type="button" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation(); openLogMaterialModal();">
<i class="bi bi-qr-code-scan me-1"></i>Log Material <i class="bi bi-plus-circle me-1"></i>Log Material
</button>
<a asp-controller="Inventory" asp-action="Scan" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation();" title="Scan QR code">
<i class="bi bi-qr-code-scan"></i>
</a> </a>
</span> </span>
</div> </div>
@@ -1028,7 +1034,7 @@
{ {
<div class="card-body text-muted text-center py-3 small"> <div class="card-body text-muted text-center py-3 small">
<i class="bi bi-droplet me-1"></i>No materials have been logged for this job yet. <i class="bi bi-droplet me-1"></i>No materials have been logged for this job yet.
Use the QR label on an inventory item to log usage. Click <strong>Log Material</strong> above or scan the QR label on an inventory item.
</div> </div>
} }
else else
@@ -1089,6 +1095,78 @@
</div><!-- /collapseMaterials --> </div><!-- /collapseMaterials -->
</div> </div>
<!-- Log Material Modal -->
<div class="modal fade" id="logMaterialModal" tabindex="-1" aria-labelledby="logMaterialModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="logMaterialModalLabel">
<i class="bi bi-droplet-half me-2 text-primary"></i>Log Material Usage
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label fw-semibold">Inventory Item <span class="text-danger">*</span></label>
<div class="position-relative">
<div class="input-group">
<input type="text" class="form-control" id="lmItemSearch"
placeholder="Search by name or manufacturer&hellip;" autocomplete="off"
oninput="lmComboInput()"
onfocus="lmComboOpen()"
onkeydown="lmComboKey(event)">
<button class="btn btn-outline-secondary" type="button" tabindex="-1"
id="lmItemDropdownToggle" onclick="lmComboToggle()">
<i class="bi bi-chevron-down" style="font-size:.75rem;"></i>
</button>
</div>
<div id="lmItemDropdown"
style="display:none;max-height:220px;overflow-y:auto;z-index:1070;background:#fff;border:1px solid rgba(0,0,0,.15);border-radius:.375rem;box-shadow:0 4px 12px rgba(0,0,0,.12);">
</div>
</div>
<div id="lmItemBalance" class="form-text text-muted d-none"></div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Entry Method</label>
<div class="d-flex gap-3">
<div class="form-check">
<input class="form-check-input" type="radio" name="lmEntryMethod" id="lmMethodUsed" value="used" checked onchange="lmUpdateQuantityLabel()">
<label class="form-check-label" for="lmMethodUsed">Amount Used</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="lmEntryMethod" id="lmMethodRemaining" value="remaining" onchange="lmUpdateQuantityLabel()">
<label class="form-check-label" for="lmMethodRemaining">Amount Remaining</label>
</div>
</div>
</div>
<div class="mb-3">
<label id="lmQtyLabel" class="form-label fw-semibold">Quantity Used <span class="text-danger">*</span></label>
<input type="number" id="lmQuantity" class="form-control" min="0" step="0.01" placeholder="0.00">
<div id="lmComputedUsed" class="form-text text-muted d-none"></div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Reason</label>
<select id="lmTransactionType" class="form-select">
<option value="JobUsage">Job Usage</option>
<option value="Waste">Waste / Spillage</option>
</select>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Notes</label>
<textarea id="lmNotes" class="form-control" rows="2" placeholder="Optional"></textarea>
</div>
<div id="lmAlert" class="alert alert-permanent d-none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="lmSaveBtn" onclick="lmSave()">
<i class="bi bi-check-circle me-1"></i>Log Usage
</button>
</div>
</div>
</div>
</div>
<!-- Part Intake Modal --> <!-- Part Intake Modal -->
@{ @{
var intakeExpectedCount = Model.Items?.Sum(i => (int)i.Quantity) ?? 0; var intakeExpectedCount = Model.Items?.Sum(i => (int)i.Quantity) ?? 0;
@@ -3082,6 +3160,19 @@
} }
} }
<!-- Log Material Modal JS -->
<script src="/js/log-material.js"></script>
<script>
(function () {
const inventoryItems = @Html.Raw(ViewBag.InventoryItemsForModal ?? "[]");
const jobPowderIds = @Html.Raw(ViewBag.JobPowderIds ?? "[]");
const jobId = @Model.Id;
const logUrl = '@Url.Action("LogMaterial", "Jobs")';
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
window.__logMaterial = { inventoryItems, jobPowderIds, jobId, logUrl, token };
})();
</script>
<!-- Save as Template Modal --> <!-- Save as Template Modal -->
<div class="modal fade" id="saveTemplateModal" tabindex="-1" aria-labelledby="saveTemplateModalLabel" aria-hidden="true"> <div class="modal fade" id="saveTemplateModal" tabindex="-1" aria-labelledby="saveTemplateModalLabel" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
+46 -2
View File
@@ -190,6 +190,49 @@
</div> </div>
</div> </div>
<!-- Oven & Batch Settings -->
<div class="card mb-4">
<div class="card-header">
<div class="d-flex align-items-center gap-2">
<h5 class="mb-0"><i class="bi bi-thermometer-half me-2"></i>Oven &amp; Batch Settings</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Oven &amp; Batch Pricing"
data-bs-content="The oven cost is charged once per batch at the job level, not per item. Estimate how many oven loads the full job will fill &amp;mdash; for example, if you have 20 small parts and your oven fits 10, that&amp;#39;s 2 batches. Cycle time is how long each batch runs. The cost is calculated from your oven&amp;#39;s hourly rate in Settings.">
<i class="bi bi-question-circle"></i>
</a>
</div>
</div>
<div class="card-body">
<p class="text-muted small mb-3">Estimate how many oven loads the complete job will require. The oven cycle cost is added once at the job level, not per item.</p>
<div class="row g-3 align-items-end">
@if (ViewBag.OvenCosts != null && ((List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.OvenCosts).Count > 1)
{
<div class="col-md-4">
<label asp-for="OvenCostId" class="form-label fw-semibold"><i class="bi bi-thermometer-half me-1"></i>Oven</label>
<select asp-for="OvenCostId" asp-items="@((List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.OvenCosts)"
class="form-select" onchange="scheduleAutoPricing()"></select>
</div>
}
<div class="col-sm-3 col-md-2">
<label asp-for="OvenBatches" class="form-label fw-semibold">Batches</label>
<input asp-for="OvenBatches" class="form-control" type="number" min="1"
value="@(Model.OvenBatches > 0 ? Model.OvenBatches : 1)"
id="OvenBatches" onchange="scheduleAutoPricing()" />
</div>
<div class="col-sm-4 col-md-3">
<label asp-for="OvenCycleMinutes" class="form-label fw-semibold">
Cycle Time per Batch (min)
<small class="text-muted fw-normal">default: @(ViewBag.DefaultOvenCycleMinutes ?? 45)</small>
</label>
<input asp-for="OvenCycleMinutes" class="form-control" type="number" min="1"
id="OvenCycleMinutes" placeholder="@(ViewBag.DefaultOvenCycleMinutes ?? 45)"
onchange="scheduleAutoPricing()" />
</div>
</div>
</div>
</div>
<!-- Pricing Options (Rush / Discount) --> <!-- Pricing Options (Rush / Discount) -->
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"> <div class="card-header">
@@ -357,7 +400,8 @@
transferEfficiency = c.TransferEfficiency, transferEfficiency = c.TransferEfficiency,
powderCostPerLb = c.PowderCostPerLb, powderCostPerLb = c.PowderCostPerLb,
powderToOrder = c.PowderToOrder, powderToOrder = c.PowderToOrder,
notes = c.Notes notes = c.Notes,
noExtraLayerCharge = c.NoExtraLayerCharge
}), }),
prepServices = item.PrepServices.Select(ps => new { prepServices = item.PrepServices.Select(ps => new {
prepServiceId = ps.PrepServiceId, prepServiceId = ps.PrepServiceId,
@@ -374,7 +418,7 @@
"discountType": @Json.Serialize(Model.DiscountType), "discountType": @Json.Serialize(Model.DiscountType),
"discountValue": @Model.DiscountValue, "discountValue": @Model.DiscountValue,
"isRushJob": @Json.Serialize(Model.IsRushJob), "isRushJob": @Json.Serialize(Model.IsRushJob),
"ovenCostId": null, "ovenCostId": @Json.Serialize(Model.OvenCostId),
"areaUnit": @Json.Serialize((string?)ViewBag.AreaUnit), "areaUnit": @Json.Serialize((string?)ViewBag.AreaUnit),
"useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)), "useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)),
"pricingUrl": "@Url.Action("CalculatePricing", "Jobs")", "pricingUrl": "@Url.Action("CalculatePricing", "Jobs")",
@@ -153,7 +153,8 @@
transferEfficiency = c.TransferEfficiency, transferEfficiency = c.TransferEfficiency,
powderCostPerLb = c.PowderCostPerLb, powderCostPerLb = c.PowderCostPerLb,
powderToOrder = c.PowderToOrder, powderToOrder = c.PowderToOrder,
notes = c.Notes notes = c.Notes,
noExtraLayerCharge = c.NoExtraLayerCharge
}), }),
prepServices = item.PrepServices.Select(ps => new { prepServices = item.PrepServices.Select(ps => new {
prepServiceId = ps.PrepServiceId, prepServiceId = ps.PrepServiceId,
@@ -2,8 +2,21 @@
@{ @{
var emailDefault = ViewBag.EmailDefaultOnComplete == true; var emailDefault = ViewBag.EmailDefaultOnComplete == true;
var preLoggedPowder = ViewBag.PreLoggedPowder as Dictionary<int, decimal> ?? new Dictionary<int, decimal>(); var preLoggedPowder = ViewBag.PreLoggedPowder as Dictionary<int, decimal> ?? new Dictionary<int, decimal>();
// Track remaining credit per InventoryItemId as we allocate it across coat rows
var remainingCredit = preLoggedPowder.ToDictionary(kv => kv.Key, kv => kv.Value); // Group all coats by inventory item so we ask once per powder color, not once per item/coat
var powderGroups = (Model.Items ?? new List<PowderCoating.Application.DTOs.Job.JobItemDto>())
.SelectMany(i => i.Coats ?? new List<PowderCoating.Application.DTOs.Job.JobItemCoatDto>())
.Where(c => c.InventoryItemId.HasValue)
.GroupBy(c => c.InventoryItemId!.Value)
.Select(g => new {
InventoryItemId = g.Key,
ColorName = g.First().ColorName,
ColorCode = g.First().ColorCode,
TotalEstimatedLbs = g.Sum(c => c.PowderToOrder ?? 0m),
PreLogged = preLoggedPowder.GetValueOrDefault(g.Key, 0m)
})
.OrderBy(g => g.ColorName)
.ToList();
} }
<div class="modal fade" id="completeJobModal" tabindex="-1"> <div class="modal fade" id="completeJobModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-lg"> <div class="modal-dialog modal-dialog-centered modal-lg">
@@ -27,102 +40,59 @@
<div class="form-text">Enter the total time in hours (e.g., 2.5 for 2 hours 30 minutes)</div> <div class="form-text">Enter the total time in hours (e.g., 2.5 for 2 hours 30 minutes)</div>
</div> </div>
@if (Model.Items != null && Model.Items.Any()) @if (powderGroups.Any())
{ {
<div class="mb-3"> <div class="mb-3">
<h6 class="fw-semibold mb-3"> <h6 class="fw-semibold mb-1">
<i class="bi bi-palette me-1 text-primary"></i>Actual Powder Usage <i class="bi bi-palette me-1 text-primary"></i>Actual Powder Usage
</h6> </h6>
<p class="text-muted small mb-3">Enter total lbs used per powder color for the entire job.</p>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-sm table-hover"> <table class="table table-sm table-hover">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th>Item</th> <th>Color / Powder</th>
<th>Coat</th>
<th>Color</th>
<th class="text-end">Estimated (lbs)</th> <th class="text-end">Estimated (lbs)</th>
<th>Actual (lbs)</th> <th style="width:150px">Actual Used (lbs)</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@{ @for (int i = 0; i < powderGroups.Count; i++)
var coatIndex = 0;
}
@foreach (var item in Model.Items)
{ {
if (item.Coats != null && item.Coats.Any()) var pg = powderGroups[i];
{ <tr>
foreach (var coat in item.Coats.OrderBy(c => c.Sequence)) <td>
{ <span class="fw-semibold">@pg.ColorName</span>
<tr> @if (!string.IsNullOrEmpty(pg.ColorCode))
<td> {
<small>@item.Description</small> <small class="text-muted ms-1">(@pg.ColorCode)</small>
@if (item.Quantity > 1) }
{ </td>
<span class="badge bg-secondary ms-1">&times;@item.Quantity</span> <td class="text-end text-muted small align-middle">
} @pg.TotalEstimatedLbs.ToString("0.##")
</td> </td>
<td><span class="badge bg-secondary">@coat.CoatName</span></td> <td>
<td> <input type="hidden" name="PowderUsages[@i].InventoryItemId" value="@pg.InventoryItemId" />
@if (!string.IsNullOrEmpty(coat.ColorName)) <input type="number"
{ class="form-control form-control-sm"
<small> name="PowderUsages[@i].ActualPowderUsedLbs"
@coat.ColorName step="0.01" min="0" placeholder="0.00"
@if (!string.IsNullOrEmpty(coat.ColorCode)) value="@(pg.PreLogged > 0 ? pg.PreLogged.ToString("0.##") : "")">
{ @if (pg.PreLogged > 0)
<span class="text-muted">(@coat.ColorCode)</span> {
} <small class="text-success d-block mt-1">
</small> <i class="bi bi-check-circle me-1"></i>@pg.PreLogged.ToString("0.##") lbs already logged
}
</td>
<td class="text-end">
<small class="text-muted">@((coat.PowderToOrder ?? 0).ToString("0.##"))</small>
</td>
<td>
@{
decimal preFilledLbs = 0m;
if (coat.InventoryItemId.HasValue && remainingCredit.TryGetValue(coat.InventoryItemId.Value, out var availCredit) && availCredit > 0)
{
preFilledLbs = availCredit;
remainingCredit[coat.InventoryItemId.Value] = 0m;
}
}
<input type="hidden" name="CoatUsages[@coatIndex].JobItemCoatId" value="@coat.Id" />
<input type="number"
class="form-control form-control-sm"
name="CoatUsages[@coatIndex].ActualPowderUsedLbs"
step="0.01" min="0" placeholder="0.00"
value="@(preFilledLbs > 0 ? preFilledLbs.ToString("0.##") : "")"
style="max-width: 120px;">
@if (preFilledLbs > 0)
{
<small class="text-success d-block mt-1">
<i class="bi bi-check-circle me-1"></i>Already logged — inventory adjusted
</small>
}
</td>
</tr>
coatIndex++;
}
}
else
{
<tr class="table-secondary">
<td colspan="5">
<small class="text-muted fst-italic">
<i class="bi bi-info-circle me-1"></i>
@item.Description — No coat information available (legacy job item)
</small> </small>
</td> }
</tr> </td>
} </tr>
} }
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="alert alert-info alert-permanent mb-0"> <div class="alert alert-info alert-permanent mb-0">
<i class="bi bi-info-circle me-2"></i> <i class="bi bi-info-circle me-2"></i>
<small>Pre-filled values were already logged via scan inventory is already adjusted for those. You can edit the amount; only the difference will be applied to inventory.</small> <small>Pre-filled values were already logged via scan &mdash; inventory is already adjusted for those. You can edit the amount; only the difference will be applied.</small>
</div> </div>
</div> </div>
} }
@@ -479,7 +479,8 @@
transferEfficiency = c.TransferEfficiency, transferEfficiency = c.TransferEfficiency,
powderCostPerLb = c.PowderCostPerLb, powderCostPerLb = c.PowderCostPerLb,
powderToOrder = c.PowderToOrder, powderToOrder = c.PowderToOrder,
notes = c.Notes notes = c.Notes,
noExtraLayerCharge = c.NoExtraLayerCharge
}) })
}))) })))
</script> </script>
@@ -525,7 +525,8 @@
transferEfficiency = c.TransferEfficiency, transferEfficiency = c.TransferEfficiency,
powderCostPerLb = c.PowderCostPerLb, powderCostPerLb = c.PowderCostPerLb,
powderToOrder = c.PowderToOrder, powderToOrder = c.PowderToOrder,
notes = c.Notes notes = c.Notes,
noExtraLayerCharge = c.NoExtraLayerCharge
}) })
}))) })))
</script> </script>
@@ -82,6 +82,21 @@ document.addEventListener('DOMContentLoaded', () => {
const ownerForm = hfc?.closest('form'); const ownerForm = hfc?.closest('form');
if (ownerForm) { if (ownerForm) {
ownerForm.addEventListener('submit', writeHiddenFields, { capture: true }); ownerForm.addEventListener('submit', writeHiddenFields, { capture: true });
// Save scroll position before the form causes a full-page reload so we can
// restore it after the server redirects back to this page. Key is path-specific
// so navigating away and back doesn't restore a stale position.
const scrollKey = 'wizardScrollY:' + location.pathname;
ownerForm.addEventListener('submit', () => {
sessionStorage.setItem(scrollKey, String(Math.round(window.scrollY)));
}, { capture: true });
// Restore on load — fire after layout is painted so scrollTo lands correctly.
const savedY = sessionStorage.getItem(scrollKey);
if (savedY !== null) {
sessionStorage.removeItem(scrollKey);
requestAnimationFrame(() => window.scrollTo({ top: parseInt(savedY, 10), behavior: 'instant' }));
}
} }
// Close any open powder combobox or catalog lookup dropdown when clicking outside it // Close any open powder combobox or catalog lookup dropdown when clicking outside it
@@ -2731,8 +2746,9 @@ function buildCardHtml(item, i) {
const orderBadge = (!c.inventoryItemId && c.powderToOrder) const orderBadge = (!c.inventoryItemId && c.powderToOrder)
? ` <span class="badge bg-warning text-dark" style="font-size:.65em;vertical-align:middle;" title="Custom powder — must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER ${parseFloat(c.powderToOrder).toFixed(2)} lbs</span>` ? ` <span class="badge bg-warning text-dark" style="font-size:.65em;vertical-align:middle;" title="Custom powder — must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER ${parseFloat(c.powderToOrder).toFixed(2)} lbs</span>`
: ''; : '';
const coatNotes = c.notes ? `<span class="fst-italic ms-1 opacity-75">— ${escHtml(c.notes)}</span>` : '';
return `<div style="font-size:.8rem;" class="text-muted mt-1"> return `<div style="font-size:.8rem;" class="text-muted mt-1">
<i class="bi bi-layers me-1"></i><span class="fw-semibold">${escHtml(c.coatName || 'Coat')}</span>${color ? ` ${color}${code}` : ''}${orderBadge} <i class="bi bi-layers me-1"></i><span class="fw-semibold">${escHtml(c.coatName || 'Coat')}</span>${color ? ` ${color}${code}` : ''}${orderBadge}${coatNotes}
</div>`; </div>`;
}).join(''); }).join('');
@@ -3414,7 +3430,7 @@ function loadItemsFromTemplate(templateItems) {
coverageSqFtPerLb: c.coverageSqFtPerLb || 30, coverageSqFtPerLb: c.coverageSqFtPerLb || 30,
transferEfficiency: c.transferEfficiency || 65, transferEfficiency: c.transferEfficiency || 65,
powderCostPerLb: c.powderCostPerLb || null, powderCostPerLb: c.powderCostPerLb || null,
noExtraLayerCharge: false noExtraLayerCharge: !!c.noExtraLayerCharge
})), })),
prepServices: (ti.prepServices || []).map(p => ({ prepServices: (ti.prepServices || []).map(p => ({
prepServiceId: p.prepServiceId, prepServiceId: p.prepServiceId,
@@ -0,0 +1,285 @@
/**
* Log Material Usage modal job details page.
* Reads config from window.__logMaterial injected inline by the view.
*/
(function () {
let _items = [];
let _jobPowderIds = new Set();
let _modal = null;
// ── Combobox state ────────────────────────────────────────────────────────
let _selectedItemId = 0;
function lmComboInput() {
const q = document.getElementById('lmItemSearch')?.value?.toLowerCase() || '';
lmComboRender(q);
lmComboShow();
_selectedItemId = 0;
document.getElementById('lmItemBalance').classList.add('d-none');
lmOnQtyInput();
}
function lmComboOpen() {
const q = document.getElementById('lmItemSearch')?.value?.toLowerCase() || '';
lmComboRender(q);
lmComboShow();
}
function lmComboToggle() {
const dd = document.getElementById('lmItemDropdown');
if (!dd) return;
if (dd.style.display === 'none' || !dd.style.display) {
lmComboOpen();
document.getElementById('lmItemSearch')?.focus();
} else {
lmComboClose();
}
}
function lmMakeRow(it) {
const display = (it.manufacturer ? escLm(it.manufacturer) + ' &ndash; ' : '') +
escLm(it.name) +
(it.unitOfMeasure ? ' <span class="text-muted" style="font-size:.82rem;">(' + escLm(it.unitOfMeasure) + ')</span>' : '');
const label = (it.manufacturer ? it.manufacturer + ' - ' : '') +
it.name +
(it.unitOfMeasure ? ' (' + it.unitOfMeasure + ')' : '');
return `<div class="lm-item-opt" style="padding:.35rem .75rem;font-size:.875rem;cursor:pointer;"
data-id="${it.id}"
data-qty="${it.quantityOnHand}"
data-uom="${escLm(it.unitOfMeasure || '')}"
data-label="${escLm(label)}"
onmousedown="event.preventDefault(); lmComboSelect(this)"
onmouseenter="this.style.background='#f0f4ff'"
onmouseleave="this.classList.contains('lm-active') ? null : this.style.background=''">
${display}
</div>`;
}
function lmComboRender(query) {
const dd = document.getElementById('lmItemDropdown');
if (!dd) return;
const filtered = query
? _items.filter(it => it.name.toLowerCase().includes(query) ||
(it.manufacturer && it.manufacturer.toLowerCase().includes(query)) ||
(it.unitOfMeasure && it.unitOfMeasure.toLowerCase().includes(query)))
: _items;
if (filtered.length === 0) {
dd.innerHTML = '<div class="px-3 py-2 text-muted small">No items match.</div>';
return;
}
const jobItems = filtered.filter(it => _jobPowderIds.has(it.id));
const otherItems = filtered.filter(it => !_jobPowderIds.has(it.id));
let html = '';
if (jobItems.length > 0) {
html += '<div class="px-3 py-1 text-muted" style="font-size:.72rem;letter-spacing:.04em;text-transform:uppercase;background:#f8f9fa;border-bottom:1px solid #dee2e6;">This Job</div>';
html += jobItems.map(lmMakeRow).join('');
if (otherItems.length > 0) {
html += '<div style="height:1px;background:#dee2e6;margin:.25rem 0;"></div>';
html += '<div class="px-3 py-1 text-muted" style="font-size:.72rem;letter-spacing:.04em;text-transform:uppercase;background:#f8f9fa;border-bottom:1px solid #dee2e6;">All Inventory</div>';
}
}
html += otherItems.map(lmMakeRow).join('');
dd.innerHTML = html;
}
function lmComboShow() {
const dd = document.getElementById('lmItemDropdown');
const anchor = document.getElementById('lmItemSearch');
if (!dd || !anchor) return;
const rect = anchor.closest('.input-group').getBoundingClientRect();
dd.style.position = 'fixed';
dd.style.top = (rect.bottom + 2) + 'px';
dd.style.left = rect.left + 'px';
dd.style.width = rect.width + 'px';
dd.style.display = 'block';
}
function lmComboClose() {
const dd = document.getElementById('lmItemDropdown');
if (dd) dd.style.display = 'none';
}
window.lmComboSelect = function (el) {
_selectedItemId = parseInt(el.dataset.id) || 0;
document.getElementById('lmItemSearch').value = el.dataset.label;
lmComboClose();
const qty = parseFloat(el.dataset.qty) || 0;
const uom = el.dataset.uom;
const balDiv = document.getElementById('lmItemBalance');
balDiv.textContent = 'Current stock: ' + qty.toFixed(2) + (uom ? ' ' + uom : '');
balDiv.classList.remove('d-none');
lmOnQtyInput();
};
window.lmComboInput = lmComboInput;
window.lmComboOpen = lmComboOpen;
window.lmComboToggle = lmComboToggle;
window.lmComboKey = function (event) {
const dd = document.getElementById('lmItemDropdown');
if (!dd || dd.style.display === 'none') {
if (event.key === 'ArrowDown' || event.key === 'Enter') {
event.preventDefault();
lmComboOpen();
}
return;
}
const opts = Array.from(dd.querySelectorAll('.lm-item-opt'));
let idx = opts.findIndex(o => o.classList.contains('lm-active'));
if (event.key === 'ArrowDown') {
event.preventDefault();
idx = Math.min(idx + 1, opts.length - 1);
opts.forEach(o => { o.classList.remove('lm-active'); o.style.background = ''; });
if (opts[idx]) { opts[idx].classList.add('lm-active'); opts[idx].style.background = '#e8eeff'; opts[idx].scrollIntoView({ block: 'nearest' }); }
} else if (event.key === 'ArrowUp') {
event.preventDefault();
idx = Math.max(idx - 1, 0);
opts.forEach(o => { o.classList.remove('lm-active'); o.style.background = ''; });
if (opts[idx]) { opts[idx].classList.add('lm-active'); opts[idx].style.background = '#e8eeff'; opts[idx].scrollIntoView({ block: 'nearest' }); }
} else if (event.key === 'Enter') {
event.preventDefault();
const active = dd.querySelector('.lm-active') || opts[0];
if (active) active.dispatchEvent(new MouseEvent('mousedown'));
} else if (event.key === 'Escape') {
lmComboClose();
}
};
function escLm(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── Quantity / label logic ────────────────────────────────────────────────
function lmOnQtyInput() {
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
if (method !== 'remaining') {
document.getElementById('lmComputedUsed').classList.add('d-none');
return;
}
if (!_selectedItemId) {
document.getElementById('lmComputedUsed').classList.add('d-none');
return;
}
const item = _items.find(it => it.id === _selectedItemId);
const onHand = item ? (parseFloat(item.quantityOnHand) || 0) : 0;
const remaining = parseFloat(document.getElementById('lmQuantity').value) || 0;
const used = onHand - remaining;
const computedDiv = document.getElementById('lmComputedUsed');
computedDiv.textContent = 'Usage = ' + onHand.toFixed(2) + ' ' + remaining.toFixed(2) + ' = ' + used.toFixed(2) + (item?.unitOfMeasure ? ' ' + item.unitOfMeasure : '');
computedDiv.classList.remove('d-none');
}
window.lmUpdateQuantityLabel = function () {
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
document.getElementById('lmQtyLabel').innerHTML =
(method === 'remaining' ? 'Quantity Remaining' : 'Quantity Used') +
' <span class="text-danger">*</span>';
lmOnQtyInput();
};
// ── Modal open / save ─────────────────────────────────────────────────────
window.openLogMaterialModal = function () {
_selectedItemId = 0;
document.getElementById('lmItemSearch').value = '';
document.getElementById('lmItemBalance').classList.add('d-none');
document.getElementById('lmQuantity').value = '';
document.getElementById('lmComputedUsed').classList.add('d-none');
document.getElementById('lmTransactionType').value = 'JobUsage';
document.getElementById('lmNotes').value = '';
document.getElementById('lmAlert').classList.add('d-none');
document.getElementById('lmSaveBtn').disabled = false;
document.getElementById('lmMethodUsed').checked = true;
window.lmUpdateQuantityLabel();
lmComboClose();
if (_modal) _modal.show();
};
window.lmSave = async function () {
const cfg = window.__logMaterial;
const alertEl = document.getElementById('lmAlert');
function showError(msg) {
alertEl.className = 'alert alert-danger alert-permanent';
alertEl.textContent = msg;
alertEl.classList.remove('d-none');
}
if (!_selectedItemId) { showError('Please select an inventory item.'); return; }
const qtyInput = parseFloat(document.getElementById('lmQuantity').value) || 0;
if (qtyInput <= 0) { showError('Please enter a quantity greater than zero.'); return; }
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
let quantityUsed = qtyInput;
if (method === 'remaining') {
const item = _items.find(it => it.id === _selectedItemId);
const onHand = item ? (parseFloat(item.quantityOnHand) || 0) : 0;
quantityUsed = onHand - qtyInput;
if (quantityUsed <= 0) {
showError('Remaining quantity cannot be equal to or greater than the current stock (' + onHand.toFixed(2) + ').');
return;
}
}
const btn = document.getElementById('lmSaveBtn');
btn.disabled = true;
alertEl.classList.add('d-none');
try {
const resp = await fetch(cfg.logUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': cfg.token
},
body: JSON.stringify({
jobId: cfg.jobId,
inventoryItemId: _selectedItemId,
quantityUsed: quantityUsed,
transactionType: document.getElementById('lmTransactionType').value,
notes: document.getElementById('lmNotes').value.trim() || null
})
});
const data = await resp.json();
if (data.success) {
if (_modal) _modal.hide();
window.location.reload();
} else {
showError(data.message || 'An error occurred.');
btn.disabled = false;
}
} catch {
showError('Network error. Please try again.');
btn.disabled = false;
}
};
// ── Init ──────────────────────────────────────────────────────────────────
function init() {
const cfg = window.__logMaterial;
if (!cfg) return;
_items = cfg.inventoryItems || [];
_jobPowderIds = new Set(cfg.jobPowderIds || []);
_modal = new bootstrap.Modal(document.getElementById('logMaterialModal'));
document.getElementById('lmQuantity').addEventListener('input', lmOnQtyInput);
// Close dropdown when clicking outside
document.addEventListener('click', function (e) {
if (!e.target.closest('#lmItemSearch') &&
!e.target.closest('#lmItemDropdown') &&
!e.target.closest('#lmItemDropdownToggle')) {
lmComboClose();
}
});
}
document.addEventListener('DOMContentLoaded', init);
})();
@@ -0,0 +1,284 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Infrastructure.Repositories;
using PowderCoating.Web.Controllers;
namespace PowderCoating.UnitTests;
/// <summary>
/// Verifies that the explicit <c>CompanyId == companyId</c> predicates added to every
/// user-facing controller action actually prevent cross-tenant data leakage.
///
/// Each test seeds entities for TWO companies, creates a controller whose ITenantContext
/// returns Company 1's ID, calls the action, and asserts that Company 2's data never
/// appears in the result.
///
/// These tests validate the defense-in-depth layer (explicit predicates in controllers)
/// independently of the EF Core global query filters, which behave differently on the
/// in-memory provider when no HttpContext is present.
/// </summary>
public class MultiTenantIsolationTests
{
// ── Repository-level isolation ────────────────────────────────────────────
/// <summary>
/// FindAsync with an explicit CompanyId predicate returns only the matching company's rows,
/// even when rows from other companies exist in the database.
/// </summary>
[Fact]
public async Task Repository_FindAsync_WithCompanyIdPredicate_ExcludesOtherTenants()
{
await using var context = CreateContext();
context.Customers.Add(MakeCustomer(id: 1, companyId: 1, firstName: "Alice"));
context.Customers.Add(MakeCustomer(id: 2, companyId: 2, firstName: "Bob"));
context.Customers.Add(MakeCustomer(id: 3, companyId: 1, firstName: "Carol"));
await context.SaveChangesAsync();
var uow = new UnitOfWork(context);
var results = (await uow.Customers.FindAsync(c => c.CompanyId == 1)).ToList();
Assert.Equal(2, results.Count);
Assert.All(results, c => Assert.Equal(1, c.CompanyId));
Assert.DoesNotContain(results, c => c.ContactFirstName == "Bob");
}
[Fact]
public async Task Repository_FindAsync_ReturnsEmpty_WhenNoMatchingCompanyId()
{
await using var context = CreateContext();
context.Customers.Add(MakeCustomer(id: 1, companyId: 2, firstName: "Bob"));
await context.SaveChangesAsync();
var uow = new UnitOfWork(context);
var results = await uow.Customers.FindAsync(c => c.CompanyId == 1);
Assert.Empty(results);
}
// ── SmsConsentAuditController ─────────────────────────────────────────────
[Fact]
public async Task SmsConsentAudit_Index_ReturnsOnlyCurrentCompanyCustomers()
{
await using var context = CreateContext();
context.Customers.Add(MakeCustomer(id: 1, companyId: 1, firstName: "Alice"));
context.Customers.Add(MakeCustomer(id: 2, companyId: 2, firstName: "Bob")); // other company
context.Customers.Add(MakeCustomer(id: 3, companyId: 1, firstName: "Carol"));
await context.SaveChangesAsync();
var controller = new SmsConsentAuditController(
new UnitOfWork(context),
MockTenant(companyId: 1),
Mock.Of<ILogger<SmsConsentAuditController>>());
SetHttpContext(controller);
var result = await controller.Index();
var view = Assert.IsType<ViewResult>(result);
var vm = Assert.IsType<SmsConsentAuditViewModel>(view.Model);
Assert.Equal(2, vm.TotalCount);
Assert.DoesNotContain(vm.Rows, r => r.CustomerName.Contains("Bob"));
}
[Fact]
public async Task SmsConsentAudit_ExportCsv_ContainsOnlyCurrentCompanyCustomers()
{
await using var context = CreateContext();
context.Customers.Add(MakeCustomer(id: 1, companyId: 1, firstName: "Alice"));
context.Customers.Add(MakeCustomer(id: 2, companyId: 2, firstName: "Bob"));
await context.SaveChangesAsync();
var controller = new SmsConsentAuditController(
new UnitOfWork(context),
MockTenant(companyId: 1),
Mock.Of<ILogger<SmsConsentAuditController>>());
SetHttpContext(controller);
var result = await controller.ExportCsv();
var file = Assert.IsType<FileContentResult>(result);
var csv = System.Text.Encoding.UTF8.GetString(file.FileContents);
Assert.Contains("Alice", csv);
Assert.DoesNotContain("Bob", csv);
}
// ── TaxRatesController ────────────────────────────────────────────────────
[Fact]
public async Task TaxRates_Index_ReturnsOnlyCurrentCompanyRates()
{
await using var context = CreateContext();
context.TaxRates.Add(MakeTaxRate(id: 1, companyId: 1, name: "State Tax"));
context.TaxRates.Add(MakeTaxRate(id: 2, companyId: 2, name: "Foreign Tax")); // other company
context.TaxRates.Add(MakeTaxRate(id: 3, companyId: 1, name: "Local Tax"));
await context.SaveChangesAsync();
var controller = new TaxRatesController(
new UnitOfWork(context),
MockTenant(companyId: 1),
Mock.Of<ILogger<TaxRatesController>>());
SetHttpContext(controller);
var result = await controller.Index();
var view = Assert.IsType<ViewResult>(result);
var rates = Assert.IsAssignableFrom<IEnumerable<TaxRate>>(view.Model).ToList();
Assert.Equal(2, rates.Count);
Assert.All(rates, r => Assert.Equal(1, r.CompanyId));
Assert.DoesNotContain(rates, r => r.Name == "Foreign Tax");
}
// ── RecurringTemplatesController ──────────────────────────────────────────
[Fact]
public async Task RecurringTemplates_Index_ReturnsOnlyCurrentCompanyTemplates()
{
await using var context = CreateContext();
context.RecurringTemplates.Add(MakeRecurringTemplate(id: 1, companyId: 1, name: "Monthly Rent"));
context.RecurringTemplates.Add(MakeRecurringTemplate(id: 2, companyId: 2, name: "Other Tenant Bill")); // other company
await context.SaveChangesAsync();
var controller = new RecurringTemplatesController(
new UnitOfWork(context),
MockTenant(companyId: 1),
CreateUserManagerMock().Object,
Mock.Of<ILogger<RecurringTemplatesController>>());
SetHttpContext(controller);
var result = await controller.Index();
var view = Assert.IsType<ViewResult>(result);
var templates = Assert.IsAssignableFrom<IEnumerable<RecurringTemplate>>(view.Model).ToList();
Assert.Single(templates);
Assert.Equal("Monthly Rent", templates[0].Name);
}
// ── JobTemplatesController ────────────────────────────────────────────────
[Fact]
public async Task JobTemplates_Index_ReturnsOnlyCurrentCompanyTemplates()
{
await using var context = CreateContext();
context.JobTemplates.Add(MakeJobTemplate(id: 1, companyId: 1, name: "Standard Wheel Coat"));
context.JobTemplates.Add(MakeJobTemplate(id: 2, companyId: 2, name: "Other Company Template")); // other company
await context.SaveChangesAsync();
var controller = new JobTemplatesController(
new UnitOfWork(context),
MockTenant(companyId: 1));
SetHttpContext(controller);
var result = await controller.Index();
var view = Assert.IsType<ViewResult>(result);
var templates = Assert.IsAssignableFrom<IEnumerable<JobTemplate>>(view.Model).ToList();
Assert.Single(templates);
Assert.Equal("Standard Wheel Coat", templates[0].Name);
}
// ── Cross-tenant write protection ─────────────────────────────────────────
/// <summary>
/// Verifies that the companyId-scoped FindAsync used for SMS export returns zero
/// rows for a company that has no customers, even when another company has many.
/// Guards against the "empty predicate returns all" regression.
/// </summary>
[Fact]
public async Task SmsConsentAudit_ExportCsv_IsEmpty_WhenCompanyHasNoCustomers()
{
await using var context = CreateContext();
context.Customers.Add(MakeCustomer(id: 1, companyId: 2, firstName: "Other"));
context.Customers.Add(MakeCustomer(id: 2, companyId: 2, firstName: "Also Other"));
await context.SaveChangesAsync();
var controller = new SmsConsentAuditController(
new UnitOfWork(context),
MockTenant(companyId: 1), // Company 1 has no customers
Mock.Of<ILogger<SmsConsentAuditController>>());
SetHttpContext(controller);
var result = await controller.ExportCsv();
var file = Assert.IsType<FileContentResult>(result);
var csv = System.Text.Encoding.UTF8.GetString(file.FileContents);
// Only header row, no data rows
Assert.DoesNotContain("Other", csv);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
/// <summary>Returns a mock ITenantContext that always yields the given companyId.</summary>
private static ITenantContext MockTenant(int companyId)
{
var mock = new Mock<ITenantContext>();
mock.Setup(t => t.GetCurrentCompanyId()).Returns(companyId);
return mock.Object;
}
private static void SetHttpContext(Controller controller)
{
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
};
}
private static Customer MakeCustomer(int id, int companyId, string firstName) => new()
{
Id = id,
CompanyId = companyId,
ContactFirstName = firstName,
ContactLastName = "Test",
IsCommercial = false
};
private static TaxRate MakeTaxRate(int id, int companyId, string name) => new()
{
Id = id,
CompanyId = companyId,
Name = name,
Rate = 8.5m
};
private static RecurringTemplate MakeRecurringTemplate(int id, int companyId, string name) => new()
{
Id = id,
CompanyId = companyId,
Name = name,
TemplateType = RecurringTemplateType.Bill,
Frequency = RecurringFrequency.Monthly,
IntervalCount = 1,
NextFireDate = DateTime.Today,
IsActive = true
};
private static JobTemplate MakeJobTemplate(int id, int companyId, string name) => new()
{
Id = id,
CompanyId = companyId,
Name = name
};
private static Mock<UserManager<ApplicationUser>> CreateUserManagerMock()
{
var store = new Mock<IUserStore<ApplicationUser>>();
return new Mock<UserManager<ApplicationUser>>(
store.Object, null!, null!, null!, null!, null!, null!, null!, null!);
}
}