Compare commits

...

16 Commits

Author SHA1 Message Date
spouliot 711cd01cd3 Add CRM features: Outstanding Pickups, Customer Notes, Clone Job, Preferred Powders
- Outstanding Pickups card on Customer Details shows jobs awaiting pickup with age badges
- Customer Notes log: inline add/delete notes with important flag, AJAX-backed
- Clone Job action on Jobs controller; Repeat Last Job button on Customer Details quick actions
- Preferred Powders per customer: typeahead inventory search, AJAX add/remove
- CustomerPreferredPowder entity + migration; unit tests for CRM stats/timeline logic
- Fix EF Core concurrency bug: parallel Task.WhenAll FindAsync replaced with sequential awaits

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 19:59:32 -04:00
spouliot 7cbae31916 Fix invoice ProjectName not pre-filling on edit; add to Details view
Edit GET now falls back to job.ProjectName for invoices created before the
column was added. Details view shows Project Name alongside Customer PO.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 08:58:09 -04:00
spouliot 9367e358d9 Add Project Name field to invoice create and edit forms
Stores ProjectName on the Invoice entity (previously only inherited from the
linked job at display time). Pre-fills from the job when creating from a job.
Migration: AddInvoiceProjectName.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 08:50:02 -04:00
spouliot 9f1460c9c0 Make Low Stock stat card clickable to filter inventory by low stock items
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:48:04 -04:00
spouliot 94e536178c Add optional Project Name field to quotes, jobs, and printed documents
- Add ProjectName (nvarchar 100, nullable) to Quote and Job entities;
  migration AddProjectNameToQuotesAndJobs applied
- Add ProjectName to all relevant DTOs: QuoteDto/Create/Update,
  JobDto/List/Create/Update, InvoiceDto (mapped from Job.ProjectName
  via AutoMapper so the invoice PDF picks it up without a separate column)
- Form field added after Customer PO in Quote Create/Edit and Job Create/Edit
- CreateJobFromQuote copies ProjectName from quote to job automatically
- Details views (Quote and Job) display Project when set
- Printable quote PDF: Project row in the quote details block
- Work order: Project row in customer/job info section
- Invoice PDF: Project shown in the Job Reference block alongside Job # and PO #

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 14:48:28 -04:00
spouliot 456d054229 Fix prospect quote conversion losing the job; add reply-to in email footer
QuotesController — ConvertToCustomer POST was wrongly setting the quote
status to 'Converted' (which means a job exists) and redirecting to the
customer page with no job created. The quote then disappeared from the
default list filter and the user had no way to create the job without
hunting for it. Fix: leave the quote at 'Approved' after customer
creation and redirect back to the quote details page with a toast
prompting the next step. 'Converted' status is now set exclusively by
CreateJobFromQuote when a job actually exists.

NotificationService — add tenant reply-to email address as a visible
line in the email footer so customers who ignore or whose mail client
doesn't honour the Reply-To header still have a clear address to contact.
Also adds Warning-level logging when no reply-to is configured for a
company so future routing issues are diagnosable from app logs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 10:35:48 -04:00
spouliot f38a1e3273 Add Reply-To diagnostic logging to GetEmailFromAsync
Logs a Warning when no Reply-To email is configured for a company
(so the logs show why replies land at the platform sender address)
and a Debug entry when one is set, making future send issues
diagnosable without needing the SendGrid Activity API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 11:08:45 -04:00
spouliot 03b425a12f Update blast rate tests to match nozzle-primary formula
Remove the CFM=0→zero test (CFM no longer in the formula path).
Update expected values to match the new nozzle-primary tables and
corrected TierDefaults CFM/nozzle pairings. Add WetBlasting and
RustAndScale substrate coverage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 16:21:39 -04:00
spouliot 8453449833 Recalibrate blast rate formula from industry reference tables
Replace the inaccurate CFM-based formula with nozzle-primary tables
sourced from industry standard abrasive blast cleaning references:
- Pressure pot: midpoints averaged from two reference tables
  (#4 nozzle: 115 sqft/hr, #5: 175 sqft/hr, etc.)
- Siphon cabinet: dedicated siphon cabinet reference table
  (#4 nozzle: 125 sqft/hr, #3: 75 sqft/hr, etc.)
- SiphonPot: 80% of pressure pot rate (open gravity feed, no enclosure)
- WetBlasting: 60% of pressure pot rate (water-media reduces velocity)

CFM is removed from the rate formula entirely — nozzle size determines
throughput and CFM draw, so CFM is a consequence of nozzle choice, not
an independent variable. Override field still bypasses formula for shops
that have measured their own throughput.

Also corrects TierDefaults nozzle/CFM pairings which were mismatched
(e.g. Small tier had 40 CFM assigned to a #5 nozzle that needs 150 CFM).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 16:12:45 -04:00
spouliot ad986561c9 Fix AI quote blast rate: single formula path, correct client preview
Root cause: company-settings-lookups.js had its own baseByCfm/multiplier
tables that were completely different from ShopCapabilityCalculator.cs,
so the UI showed an inflated rate (e.g. 82 sqft/hr) while the AI prompt
received the server-computed rate (e.g. 9 sqft/hr).

- Add CompanySettingsController.DeriveBlastRate endpoint — thin GET that
  calls ShopCapabilityCalculator directly; now the single formula path
- Delete all client-side formula code (baseByCfm, multiplier tables,
  deriveBlastRate) — ~30 lines removed
- Modal live preview calls /CompanySettings/DeriveBlastRate with 250ms
  debounce instead of computing locally
- Blast setup table uses setup.derivedRate from GetBlastSetups (already
  server-computed) instead of recalculating client-side
- QuotesController.AiAnalyzeItem: when no blast setup is explicitly
  selected, fall back to the company's default blast setup so the
  configured rate is always used

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 15:57:46 -04:00
spouliot 0d5553f3b2 Fix dark mode hover colors in coat/powder dropdown menus
Replace all hardcoded light-mode colors (#f0f4ff, #e8eeff, #fff8e1, #fff)
with Bootstrap CSS variables (--bs-secondary-bg, --bs-primary-bg-subtle,
--bs-warning-bg-subtle, --bs-body-bg) so dropdown containers and hover
states render correctly in both light and dark mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 15:12:24 -04:00
spouliot 87bbf158a4 Fix material usage logging: remaining weight mode, edit modal, and consolidate duplicate logic
- InventoryController: extract RecordInventoryUsageAsync helper; both LogUsage
  (scan page) and LogMaterial (jobs modal, moved from JobsController) call it —
  no more duplicate save/GL logic across two controllers
- Log Material modal: replace radio buttons with prominent toggle buttons so the
  active mode (Amount Used vs Amount Remaining) is always visually obvious; add
  always-visible preview line showing exactly what will be logged before saving
- Edit Usage modal: add quantity field (pre-populated from existing transaction)
  with delta adjustment to InventoryItem.QuantityOnHand on save; include
  completed/terminal jobs in the dropdown so entries can be corrected after a
  job is marked done
- Scan page job picker: include jobs completed within the last 7 days (marked
  with '(completed)') so usage can be logged after a job is finished

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 14:31:02 -04:00
spouliot f453a95f28 Add hover tooltips on job list rows showing description and PO number
Adds CustomerPO to JobListDto (maps by convention), then builds a
Bootstrap tooltip per row with description · PO: xxx, skipping blank
fields. Rows with neither get no tooltip. Helps identify jobs at a
glance without opening the details page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 18:53:52 -04:00
spouliot d9e98a55d2 Fix customer email inputs to allow comma-separated addresses
type="email" triggers jQuery Validate's email rule which rejects commas,
blocking multi-address input despite the multiple attribute. Switching to
type="text" defers validation to the server-side SplitEmails/MailAddress
logic in the DTO which already handles comma-separated lists correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:16:30 -04:00
spouliot 99deca3b62 Default imported formula templates to active regardless of export state
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 11:14:48 -04:00
spouliot 23e64829bb Fix formula export/import: embed fields as real JSON array, not escaped string
Previously FieldsJson was serialized as an escaped string in the export
file, which was fragile and unreadable. Now parsed into a JsonElement and
embedded as a proper JSON array under the key "fields". Import reads it
back with GetRawText() to reconstruct the stored string. This prevents
the null/empty fields bug caused by manually-edited or round-tripped files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 11:06:38 -04:00
51 changed files with 35874 additions and 397 deletions
@@ -0,0 +1,35 @@
namespace PowderCoating.Application.DTOs.Customer;
/// <summary>A single entry in the customer activity timeline feed on the Details page.</summary>
public class CustomerTimelineEventDto
{
public DateTime Date { get; set; }
public string Icon { get; set; } = string.Empty;
public string BadgeColor { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string? Subtitle { get; set; }
public decimal? Amount { get; set; }
public int? EntityId { get; set; }
public string? LinkController { get; set; }
public string? LinkAction { get; set; }
}
/// <summary>Aggregate lifetime metrics displayed in the CRM stats card on Customer Details.</summary>
public class CustomerLifetimeStatsDto
{
public int TotalJobs { get; set; }
public int ActiveJobs { get; set; }
/// <summary>Sum of Total on non-voided invoices.</summary>
public decimal TotalRevenue { get; set; }
/// <summary>Sum of AmountPaid on non-voided invoices.</summary>
public decimal TotalCollected { get; set; }
/// <summary>Mean FinalPrice across all jobs for this customer.</summary>
public decimal AverageJobValue { get; set; }
public DateTime? LastJobDate { get; set; }
public int? DaysSinceLastJob { get; set; }
public int TotalQuotes { get; set; }
public int TotalInvoices { get; set; }
public decimal OpenBalance { get; set; }
/// <summary>Id of the most recent job — used by the "Repeat Last Job" button on Customer Details.</summary>
public int? LastJobId { get; set; }
}
@@ -57,6 +57,7 @@ public class InvoiceDto
public string? InternalNotes { get; set; } public string? InternalNotes { get; set; }
public string? Terms { get; set; } public string? Terms { get; set; }
public string? CustomerPO { get; set; } public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
public string? ExternalReference { get; set; } public string? ExternalReference { get; set; }
public int? SalesTaxAccountId { get; set; } public int? SalesTaxAccountId { get; set; }
public string? SalesTaxAccountName { get; set; } public string? SalesTaxAccountName { get; set; }
@@ -88,6 +89,7 @@ public class CreateInvoiceDto
public string? InternalNotes { get; set; } public string? InternalNotes { get; set; }
public string? Terms { get; set; } public string? Terms { get; set; }
public string? CustomerPO { get; set; } public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
/// <summary>Early-payment discount percentage parsed from the customer's payment terms (e.g., 2.0 for "2/10 Net 30"). Informational — does not auto-apply.</summary> /// <summary>Early-payment discount percentage parsed from the customer's payment terms (e.g., 2.0 for "2/10 Net 30"). Informational — does not auto-apply.</summary>
public decimal EarlyPaymentDiscountPercent { get; set; } public decimal EarlyPaymentDiscountPercent { get; set; }
/// <summary>Number of days within which the early-payment discount applies (e.g., 10 for "2/10 Net 30").</summary> /// <summary>Number of days within which the early-payment discount applies (e.g., 10 for "2/10 Net 30").</summary>
@@ -105,6 +107,7 @@ public class UpdateInvoiceDto
public string? InternalNotes { get; set; } public string? InternalNotes { get; set; }
public string? Terms { get; set; } public string? Terms { get; set; }
public string? CustomerPO { get; set; } public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
public List<CreateInvoiceItemDto> InvoiceItems { get; set; } = new(); public List<CreateInvoiceItemDto> InvoiceItems { get; set; } = new();
} }
@@ -52,6 +52,7 @@ public class JobDto
public decimal DiscountValue { get; set; } public decimal DiscountValue { get; set; }
public string? DiscountReason { get; set; } public string? DiscountReason { get; set; }
public string? CustomerPO { get; set; } public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
public string? SpecialInstructions { get; set; } public string? SpecialInstructions { get; set; }
public string? InternalNotes { get; set; } public string? InternalNotes { get; set; }
public string? Tags { get; set; } public string? Tags { get; set; }
@@ -113,6 +114,8 @@ public class JobListDto
public string? CustomerEmail { get; set; } public string? CustomerEmail { get; set; }
public bool CustomerNotifyByEmail { get; set; } = true; public bool CustomerNotifyByEmail { get; set; } = true;
public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
public DateTime? ScheduledDate { get; set; } public DateTime? ScheduledDate { get; set; }
public DateTime? DueDate { get; set; } public DateTime? DueDate { get; set; }
public decimal FinalPrice { get; set; } public decimal FinalPrice { get; set; }
@@ -166,6 +169,7 @@ public class CreateJobDto
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")] [StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
[Display(Name = "Customer PO")] [Display(Name = "Customer PO")]
public string? CustomerPO { get; set; } public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")] [StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
[Display(Name = "Special Instructions")] [Display(Name = "Special Instructions")]
@@ -251,6 +255,7 @@ public class UpdateJobDto
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")] [StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
[Display(Name = "Customer PO")] [Display(Name = "Customer PO")]
public string? CustomerPO { get; set; } public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")] [StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
[Display(Name = "Special Instructions")] [Display(Name = "Special Instructions")]
@@ -107,6 +107,7 @@ public class QuoteDto
public string? Terms { get; set; } public string? Terms { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
public string? CustomerPO { get; set; } public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
public string? Tags { get; set; } public string? Tags { get; set; }
// Items // Items
@@ -234,6 +235,7 @@ public class CreateQuoteDto
[Display(Name = "Customer PO Number")] [Display(Name = "Customer PO Number")]
[StringLength(50)] [StringLength(50)]
public string? CustomerPO { get; set; } public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
[Display(Name = "Tags")] [Display(Name = "Tags")]
[StringLength(500)] [StringLength(500)]
@@ -376,6 +378,7 @@ public class UpdateQuoteDto
[Display(Name = "Customer PO Number")] [Display(Name = "Customer PO Number")]
[StringLength(50)] [StringLength(50)]
public string? CustomerPO { get; set; } public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
[Display(Name = "Tags")] [Display(Name = "Tags")]
[StringLength(500)] [StringLength(500)]
@@ -19,6 +19,7 @@ public class InvoiceProfile : Profile
CreateMap<Invoice, InvoiceDto>() CreateMap<Invoice, InvoiceDto>()
.ForMember(d => d.JobNumber, o => o.MapFrom(s => s.Job != null ? s.Job.JobNumber : string.Empty)) .ForMember(d => d.JobNumber, o => o.MapFrom(s => s.Job != null ? s.Job.JobNumber : string.Empty))
.ForMember(d => d.ProjectName, o => o.MapFrom(s => s.ProjectName ?? (s.Job != null ? s.Job.ProjectName : null)))
.ForMember(d => d.CustomerName, o => o.MapFrom(s => s.Customer != null .ForMember(d => d.CustomerName, o => o.MapFrom(s => s.Customer != null
? (s.Customer.IsCommercial ? (s.Customer.IsCommercial
? s.Customer.CompanyName ? s.Customer.CompanyName
@@ -217,6 +217,8 @@ public class PdfService : IPdfService
c.Item().Text($"Job #: {invoice.JobNumber}"); c.Item().Text($"Job #: {invoice.JobNumber}");
if (!string.IsNullOrWhiteSpace(invoice.CustomerPO)) if (!string.IsNullOrWhiteSpace(invoice.CustomerPO))
c.Item().Text($"PO #: {invoice.CustomerPO}"); c.Item().Text($"PO #: {invoice.CustomerPO}");
if (!string.IsNullOrWhiteSpace(invoice.ProjectName))
c.Item().Text($"Project: {invoice.ProjectName}");
}); });
}); });
@@ -609,6 +611,15 @@ public class PdfService : IPdfService
row.RelativeItem().Text(quote.CustomerPO).FontSize(9); row.RelativeItem().Text(quote.CustomerPO).FontSize(9);
}); });
} }
if (!string.IsNullOrWhiteSpace(quote.ProjectName))
{
column.Item().Row(row =>
{
row.ConstantItem(80).Text("Project:").FontSize(9);
row.RelativeItem().Text(quote.ProjectName).FontSize(9);
});
}
}); });
} }
@@ -5,145 +5,165 @@ namespace PowderCoating.Application.Services;
/// <summary> /// <summary>
/// Derives sqft/hr throughput rates from a shop's equipment configuration. /// Derives sqft/hr throughput rates from a shop's equipment configuration.
/// Used in two places: the AI photo quote prompt (so Claude reasons from real shop /// Used by the AI photo quote prompt (so Claude reasons from real shop speeds)
/// speeds) and the calculated-item wizard (to show a suggested blast time hint). /// and the Company Settings live preview (so the UI always shows the same rate
/// the AI will use — single formula path, no client-side duplication).
/// ///
/// Formula: /// Both pressure pots and siphon cabinets are nozzle-primary: nozzle size
/// BlastRate = BaseByCfm(cfm) × NozzleMultiplier × SetupMultiplier × SubstrateMultiplier /// determines throughput and CFM draw. CFM is not used in the rate formula.
/// ///
/// Base rates by CFM represent a pressure pot at #5 nozzle removing paint. /// Sources:
/// All multipliers are relative to that baseline. /// Pressure pot rates — averaged from two industry standard abrasive blast
/// cleaning reference tables.
/// Siphon cabinet rates — industry reference table for siphon-fed cabinets.
/// Substrate multipliers — relative removal difficulty vs. paint baseline.
/// </summary> /// </summary>
public static class ShopCapabilityCalculator public static class ShopCapabilityCalculator
{ {
// ── Blast rate derivation ───────────────────────────────────────────────── // ── Public entry points ────────────────────────────────────────────────────
/// <summary> /// <summary>
/// Returns the effective blast rate in sqft/hr. /// Returns the effective blast rate in sqft/hr for company-level operating costs.
/// If <see cref="CompanyOperatingCosts.BlastRateSqFtPerHourOverride"/> is set, returns it directly. /// BlastRateSqFtPerHourOverride bypasses the formula when set.
/// Otherwise derives from CFM, nozzle, setup type, and substrate.
/// Returns 0 when CFM is not configured (shop hasn't calibrated yet).
/// </summary> /// </summary>
public static decimal GetBlastRateSqFtPerHour(CompanyOperatingCosts costs) public static decimal GetBlastRateSqFtPerHour(CompanyOperatingCosts costs)
{ {
if (costs.BlastRateSqFtPerHourOverride.HasValue && costs.BlastRateSqFtPerHourOverride.Value > 0) if (costs.BlastRateSqFtPerHourOverride.HasValue && costs.BlastRateSqFtPerHourOverride.Value > 0)
return costs.BlastRateSqFtPerHourOverride.Value; return costs.BlastRateSqFtPerHourOverride.Value;
if (costs.CompressorCfm <= 0) return CalculateBlastRate(costs.BlastNozzleSize, costs.BlastSetupType, costs.PrimaryBlastSubstrate);
return 0m;
var baseRate = BaseByCfm(costs.CompressorCfm);
var nozzle = NozzleMultiplier(costs.BlastNozzleSize);
var setup = SetupMultiplier(costs.BlastSetupType);
var substrate = SubstrateMultiplier(costs.PrimaryBlastSubstrate);
return Math.Round(baseRate * nozzle * setup * substrate, 1);
} }
/// <summary> /// <summary>
/// Returns the effective blast rate in sqft/hr for a named <see cref="CompanyBlastSetup"/>. /// Returns the effective blast rate in sqft/hr for a named blast setup.
/// Identical logic to the <see cref="CompanyOperatingCosts"/> overload — uses override if set, /// BlastRateSqFtPerHourOverride bypasses the formula when set.
/// otherwise derives from the setup's equipment specs.
/// </summary> /// </summary>
public static decimal GetBlastRateSqFtPerHour(CompanyBlastSetup setup) public static decimal GetBlastRateSqFtPerHour(CompanyBlastSetup setup)
{ {
if (setup.BlastRateSqFtPerHourOverride.HasValue && setup.BlastRateSqFtPerHourOverride.Value > 0) if (setup.BlastRateSqFtPerHourOverride.HasValue && setup.BlastRateSqFtPerHourOverride.Value > 0)
return setup.BlastRateSqFtPerHourOverride.Value; return setup.BlastRateSqFtPerHourOverride.Value;
if (setup.CompressorCfm <= 0) return CalculateBlastRate(setup.BlastNozzleSize, setup.SetupType, setup.PrimarySubstrate);
return 0m;
var baseRate = BaseByCfm(setup.CompressorCfm);
var nozzle = NozzleMultiplier(setup.BlastNozzleSize);
var setupMult = SetupMultiplier(setup.SetupType);
var substrate = SubstrateMultiplier(setup.PrimarySubstrate);
return Math.Round(baseRate * nozzle * setupMult * substrate, 1);
} }
/// <summary> /// <summary>
/// Returns the effective coating application rate in sqft/hr. /// Returns the effective coating application rate in sqft/hr.
/// If override is set, returns it directly. /// Override bypasses the formula when set.
/// Otherwise derives a sensible default from gun type.
/// </summary> /// </summary>
public static decimal GetCoatingRateSqFtPerHour(CompanyOperatingCosts costs) public static decimal GetCoatingRateSqFtPerHour(CompanyOperatingCosts costs)
{ {
if (costs.CoatingRateSqFtPerHourOverride.HasValue && costs.CoatingRateSqFtPerHourOverride.Value > 0) if (costs.CoatingRateSqFtPerHourOverride.HasValue && costs.CoatingRateSqFtPerHourOverride.Value > 0)
return costs.CoatingRateSqFtPerHourOverride.Value; return costs.CoatingRateSqFtPerHourOverride.Value;
// Corona and tribo guns are roughly similar on flat parts; tribo edges out on complex geometry.
// Without more equipment data (voltage, gun model) we use a single reasonable default.
return costs.CoatingGunType switch return costs.CoatingGunType switch
{ {
CoatingGunType.Corona => 40m, CoatingGunType.Corona => 40m,
CoatingGunType.Tribo => 35m, // slower on flat but better on complex; conservative default CoatingGunType.Tribo => 35m,
CoatingGunType.Both => 40m, CoatingGunType.Both => 40m,
_ => 40m _ => 40m
}; };
} }
/// <summary> /// <summary>
/// Returns default equipment field values for a given capability tier. /// Returns default equipment field values for a given capability tier, applied
/// Applied during Setup Wizard tier selection so the shop gets reasonable /// during Setup Wizard tier selection so new shops get reasonable starting values.
/// starting values even if they never visit the Quoting Calibration tab. /// CFM defaults reflect typical compressor sizes for each tier; they appear in the
/// UI for reference but are not used in the rate formula.
/// </summary> /// </summary>
public static (BlastSetupType SetupType, decimal Cfm, int NozzleSize, BlastSubstrateType Substrate) public static (BlastSetupType SetupType, decimal Cfm, int NozzleSize, BlastSubstrateType Substrate)
TierDefaults(ShopCapabilityTier tier) => tier switch TierDefaults(ShopCapabilityTier tier) => tier switch
{ {
ShopCapabilityTier.Garage => (BlastSetupType.SiphonCabinet, 7m, 4, BlastSubstrateType.Mixed), ShopCapabilityTier.Garage => (BlastSetupType.SiphonCabinet, 7m, 3, BlastSubstrateType.Mixed),
ShopCapabilityTier.Small => (BlastSetupType.PressurePot, 40m, 5, BlastSubstrateType.Mixed), ShopCapabilityTier.Small => (BlastSetupType.PressurePot, 49m, 3, BlastSubstrateType.Mixed),
ShopCapabilityTier.Medium => (BlastSetupType.PressurePot, 80m, 5, BlastSubstrateType.Mixed), ShopCapabilityTier.Medium => (BlastSetupType.PressurePot, 90m, 4, BlastSubstrateType.Mixed),
ShopCapabilityTier.Large => (BlastSetupType.PressurePot, 150m, 6, BlastSubstrateType.Mixed), ShopCapabilityTier.Large => (BlastSetupType.PressurePot, 150m, 5, BlastSubstrateType.Mixed),
_ => (BlastSetupType.SiphonCabinet, 7m, 4, BlastSubstrateType.Mixed) _ => (BlastSetupType.SiphonCabinet, 7m, 3, BlastSubstrateType.Mixed)
}; };
// ── Private helpers ─────────────────────────────────────────────────────── // ── Core formula (single path for all callers) ─────────────────────────────
/// <summary> /// <summary>
/// Base sqft/hr at a pressure pot, #5 nozzle, removing paint. /// Nozzle-primary blast rate calculation. Nozzle size determines throughput;
/// Calibrated so that real-world examples produce expected results: /// setup type routes to the appropriate reference table; substrate adjusts for
/// - 7 CFM siphon cabinet → ~2 sqft/hr (garage coater, 3+ hrs/wheel) /// removal difficulty. CFM is not used — it is a consequence of nozzle choice,
/// - 40 CFM pressure pot → ~15 sqft/hr (small shop, ~30 min/wheel) /// not an independent variable in throughput.
/// - 80 CFM pressure pot → ~25 sqft/hr (medium shop)
/// - 150 CFM pressure pot → ~40 sqft/hr (large shop, ~10 min/wheel)
/// </summary> /// </summary>
private static decimal BaseByCfm(decimal cfm) => cfm switch private static decimal CalculateBlastRate(int nozzle, BlastSetupType setupType, BlastSubstrateType substrate)
{ {
< 10 => 5m, var baseRate = setupType switch
< 20 => 9m, {
< 40 => 15m, BlastSetupType.PressurePot => PressurePotRateByNozzle(nozzle),
< 80 => 25m, BlastSetupType.SiphonCabinet => SiphonCabinetRateByNozzle(nozzle),
< 120 => 35m, // Siphon pot: open gravity feed, no enclosure penalty, ~80% of pressure pot
_ => 45m BlastSetupType.SiphonPot => Math.Round(PressurePotRateByNozzle(nozzle) * 0.80m, 1),
// Wet blasting: water-media mix reduces impact velocity, ~60% of dry pressure pot
BlastSetupType.WetBlasting => Math.Round(PressurePotRateByNozzle(nozzle) * 0.60m, 1),
_ => 0m
}; };
private static decimal NozzleMultiplier(int nozzle) => nozzle switch return Math.Round(baseRate * SubstrateMultiplier(substrate), 1);
}
/// <summary>
/// Midpoint cleaning rates for a pressure pot at adequate air supply, by nozzle size.
/// Averaged from two industry-standard abrasive blast cleaning reference tables.
/// #1 (1/16"): 20-35 sqft/hr avg → 20
/// #2 (1/8"): 40-60 sqft/hr avg → 40
/// #3 (3/16"): 60-85 sqft/hr avg → 75
/// #4 (1/4"): 90-110 sqft/hr avg → 115
/// #5 (5/16"): 130-160 sqft/hr avg → 175
/// #6 (3/8"): 180-230 sqft/hr avg → 245
/// #7 (7/16"): 240-300 sqft/hr avg → 325
/// #8 (1/2"): 320-400 sqft/hr avg → 430
/// </summary>
private static decimal PressurePotRateByNozzle(int nozzle) => nozzle switch
{ {
2 => 0.35m, 1 => 20m,
3 => 0.55m, 2 => 40m,
4 => 0.75m, 3 => 75m,
5 => 1.00m, 4 => 115m,
6 => 1.30m, 5 => 175m,
7 => 1.65m, 6 => 245m,
8 => 2.00m, 7 => 325m,
_ => 1.00m 8 => 430m,
_ => 100m
}; };
private static decimal SetupMultiplier(BlastSetupType setup) => setup switch /// <summary>
/// Midpoint cleaning rates for siphon-fed blast cabinets, by nozzle size.
/// Source: industry reference table for siphon cabinet production rates.
/// #1 (1/16"): 10-25 sqft/hr → 18
/// #2 (1/8"): 25-50 sqft/hr → 38
/// #3 (3/16"): 50-100 sqft/hr → 75
/// #4 (1/4"): 100-150 sqft/hr → 125
/// #5 (5/16"): 150-225 sqft/hr → 188
/// #6 (3/8"): 225-300 sqft/hr → 263
/// #7 (7/16"): 300-375 sqft/hr → 338
/// #8 (1/2"): 375-450 sqft/hr → 413
/// </summary>
private static decimal SiphonCabinetRateByNozzle(int nozzle) => nozzle switch
{ {
BlastSetupType.SiphonCabinet => 0.50m, // enclosed, low pressure, repositioning time 1 => 18m,
BlastSetupType.SiphonPot => 0.70m, 2 => 38m,
BlastSetupType.PressurePot => 1.00m, // baseline 3 => 75m,
BlastSetupType.WetBlasting => 0.60m, 4 => 125m,
_ => 1.00m 5 => 188m,
6 => 263m,
7 => 338m,
8 => 413m,
_ => 80m
}; };
/// <summary>
/// Adjustment for substrate removal difficulty relative to paint (baseline = 1.0).
/// Powder coat strips faster than paint; rust and scale requires multiple passes.
/// </summary>
private static decimal SubstrateMultiplier(BlastSubstrateType substrate) => substrate switch private static decimal SubstrateMultiplier(BlastSubstrateType substrate) => substrate switch
{ {
BlastSubstrateType.PowderCoat => 1.25m, // faster to remove than paint BlastSubstrateType.PowderCoat => 1.25m,
BlastSubstrateType.Paint => 1.00m, // baseline BlastSubstrateType.Paint => 1.00m,
BlastSubstrateType.Mixed => 0.90m, BlastSubstrateType.Mixed => 0.90m,
BlastSubstrateType.RustAndScale => 0.70m, // requires more passes BlastSubstrateType.RustAndScale => 0.70m,
_ => 0.90m _ => 0.90m
}; };
} }
@@ -48,6 +48,7 @@ public class Invoice : BaseEntity
public string? InternalNotes { get; set; } public string? InternalNotes { get; set; }
public string? Terms { get; set; } public string? Terms { get; set; }
public string? CustomerPO { get; set; } public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
/// <summary> /// <summary>
/// Early payment discount percentage (e.g., 2 means 2% discount). /// Early payment discount percentage (e.g., 2 means 2% discount).
+1
View File
@@ -47,6 +47,7 @@ public class Job : BaseEntity
// Additional Information // Additional Information
public string? CustomerPO { get; set; } public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
public string? SpecialInstructions { get; set; } public string? SpecialInstructions { get; set; }
public string? InternalNotes { get; set; } // Internal notes from quote public string? InternalNotes { get; set; } // Internal notes from quote
public string? Tags { get; set; } public string? Tags { get; set; }
+1
View File
@@ -88,6 +88,7 @@ public class Quote : BaseEntity
public string? Terms { get; set; } public string? Terms { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
public string? CustomerPO { get; set; } public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
public string? Tags { get; set; } public string? Tags { get; set; }
// Conversion tracking // Conversion tracking
@@ -152,6 +152,20 @@ public class CustomerNote : BaseEntity
public virtual Customer Customer { get; set; } = null!; public virtual Customer Customer { get; set; } = null!;
} }
/// <summary>
/// Records an inventory item as a preferred powder for a specific customer.
/// Shown on Customer Details for faster quoting of repeat orders.
/// </summary>
public class CustomerPreferredPowder : BaseEntity
{
public int CustomerId { get; set; }
public int InventoryItemId { get; set; }
public string? Notes { get; set; }
public virtual Customer Customer { get; set; } = null!;
public virtual InventoryItem InventoryItem { get; set; } = null!;
}
public class JobStatusHistory : BaseEntity public class JobStatusHistory : BaseEntity
{ {
public int JobId { get; set; } public int JobId { get; set; }
@@ -43,6 +43,7 @@ public interface IUnitOfWork : IDisposable
IJobPhotoRepository JobPhotos { get; } IJobPhotoRepository JobPhotos { get; }
IRepository<JobNote> JobNotes { get; } IRepository<JobNote> JobNotes { get; }
IRepository<CustomerNote> CustomerNotes { get; } IRepository<CustomerNote> CustomerNotes { get; }
IRepository<CustomerPreferredPowder> CustomerPreferredPowders { get; }
IRepository<JobStatusHistory> JobStatusHistory { get; } IRepository<JobStatusHistory> JobStatusHistory { get; }
IRepository<PricingTier> PricingTiers { get; } IRepository<PricingTier> PricingTiers { get; }
@@ -230,6 +230,8 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
public DbSet<JobNote> JobNotes { get; set; } public DbSet<JobNote> JobNotes { get; set; }
/// <summary>Free-text notes added to a customer record by staff; tenant-filtered with soft delete.</summary> /// <summary>Free-text notes added to a customer record by staff; tenant-filtered with soft delete.</summary>
public DbSet<CustomerNote> CustomerNotes { get; set; } public DbSet<CustomerNote> CustomerNotes { get; set; }
/// <summary>Inventory items marked as frequently used for a customer; shown on Customer Details for faster quoting.</summary>
public DbSet<CustomerPreferredPowder> CustomerPreferredPowders { get; set; }
/// <summary>Audit trail of every status transition on a job, referencing the lookup-table statuses.</summary> /// <summary>Audit trail of every status transition on a job, referencing the lookup-table statuses.</summary>
public DbSet<JobStatusHistory> JobStatusHistory { get; set; } public DbSet<JobStatusHistory> JobStatusHistory { get; set; }
/// <summary>Customer pricing tiers (Standard, Preferred, Premium); tenant-filtered with soft delete.</summary> /// <summary>Customer pricing tiers (Standard, Preferred, Premium); tenant-filtered with soft delete.</summary>
@@ -551,6 +553,8 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId)); !e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<CustomerNote>().HasQueryFilter(e => modelBuilder.Entity<CustomerNote>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId)); !e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<CustomerPreferredPowder>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<JobStatusHistory>().HasQueryFilter(e => modelBuilder.Entity<JobStatusHistory>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId)); !e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e => modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
@@ -1719,6 +1723,23 @@ modelBuilder.Entity<Job>()
.HasIndex(cn => new { cn.CustomerId, cn.CreatedAt }) .HasIndex(cn => new { cn.CustomerId, cn.CreatedAt })
.HasDatabaseName("IX_CustomerNotes_CustomerId_CreatedAt"); .HasDatabaseName("IX_CustomerNotes_CustomerId_CreatedAt");
modelBuilder.Entity<CustomerPreferredPowder>()
.HasIndex(p => new { p.CustomerId, p.InventoryItemId })
.IsUnique()
.HasDatabaseName("IX_CustomerPreferredPowders_CustomerId_InventoryItemId");
modelBuilder.Entity<CustomerPreferredPowder>()
.HasOne(p => p.Customer)
.WithMany()
.HasForeignKey(p => p.CustomerId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<CustomerPreferredPowder>()
.HasOne(p => p.InventoryItem)
.WithMany()
.HasForeignKey(p => p.InventoryItemId)
.OnDelete(DeleteBehavior.Restrict);
// =================================================================== // ===================================================================
// END PERFORMANCE OPTIMIZATION INDEXES // END PERFORMANCE OPTIMIZATION INDEXES
// =================================================================== // ===================================================================
@@ -0,0 +1,81 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddProjectNameToQuotesAndJobs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ProjectName",
table: "Quotes",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ProjectName",
table: "Jobs",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7640));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7646));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7647));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ProjectName",
table: "Quotes");
migrationBuilder.DropColumn(
name: "ProjectName",
table: "Jobs");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9377));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9381));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9382));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddInvoiceProjectName : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ProjectName",
table: "Invoices",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2471));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2477));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2478));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ProjectName",
table: "Invoices");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7640));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7646));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7647));
}
}
}
@@ -0,0 +1,110 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddCustomerPreferredPowders : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CustomerPreferredPowders",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
CustomerId = table.Column<int>(type: "int", nullable: false),
InventoryItemId = table.Column<int>(type: "int", nullable: false),
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_CustomerPreferredPowders", x => x.Id);
table.ForeignKey(
name: "FK_CustomerPreferredPowders_Customers_CustomerId",
column: x => x.CustomerId,
principalTable: "Customers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CustomerPreferredPowders_InventoryItems_InventoryItemId",
column: x => x.InventoryItemId,
principalTable: "InventoryItems",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9947));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9953));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9954));
migrationBuilder.CreateIndex(
name: "IX_CustomerPreferredPowders_CustomerId_InventoryItemId",
table: "CustomerPreferredPowders",
columns: new[] { "CustomerId", "InventoryItemId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_CustomerPreferredPowders_InventoryItemId",
table: "CustomerPreferredPowders",
column: "InventoryItemId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CustomerPreferredPowders");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2471));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2477));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2478));
}
}
}
@@ -2944,6 +2944,58 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("CustomerNotes"); b.ToTable("CustomerNotes");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerPreferredPowder", 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<int>("CustomerId")
.HasColumnType("int");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<int>("InventoryItemId")
.HasColumnType("int");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("InventoryItemId");
b.HasIndex("CustomerId", "InventoryItemId")
.IsUnique()
.HasDatabaseName("IX_CustomerPreferredPowders_CustomerId_InventoryItemId");
b.ToTable("CustomerPreferredPowders");
});
modelBuilder.Entity("PowderCoating.Core.Entities.DashboardTip", b => modelBuilder.Entity("PowderCoating.Core.Entities.DashboardTip", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -4269,6 +4321,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("PreparedById") b.Property<string>("PreparedById")
.HasColumnType("nvarchar(450)"); .HasColumnType("nvarchar(450)");
b.Property<string>("ProjectName")
.HasColumnType("nvarchar(max)");
b.Property<string>("PublicViewToken") b.Property<string>("PublicViewToken")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -4560,6 +4615,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("PricingBreakdownJson") b.Property<string>("PricingBreakdownJson")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<string>("ProjectName")
.HasColumnType("nvarchar(max)");
b.Property<int?>("QuoteId") b.Property<int?>("QuoteId")
.HasColumnType("int"); .HasColumnType("int");
@@ -7053,7 +7111,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 1, Id = 1,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9377), CreatedAt = new DateTime(2026, 6, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9947),
Description = "Standard pricing for regular customers", Description = "Standard pricing for regular customers",
DiscountPercent = 0m, DiscountPercent = 0m,
IsActive = true, IsActive = true,
@@ -7064,7 +7122,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 2, Id = 2,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9381), CreatedAt = new DateTime(2026, 6, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9953),
Description = "5% discount for preferred customers", Description = "5% discount for preferred customers",
DiscountPercent = 5m, DiscountPercent = 5m,
IsActive = true, IsActive = true,
@@ -7075,7 +7133,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 3, Id = 3,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9382), CreatedAt = new DateTime(2026, 6, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9954),
Description = "10% discount for premium customers", Description = "10% discount for premium customers",
DiscountPercent = 10m, DiscountPercent = 10m,
IsActive = true, IsActive = true,
@@ -7385,6 +7443,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<decimal>("ProfitPercent") b.Property<decimal>("ProfitPercent")
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
b.Property<string>("ProjectName")
.HasColumnType("nvarchar(max)");
b.Property<string>("ProspectAddress") b.Property<string>("ProspectAddress")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -9485,6 +9546,25 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("Customer"); b.Navigation("Customer");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerPreferredPowder", b =>
{
b.HasOne("PowderCoating.Core.Entities.Customer", "Customer")
.WithMany()
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("PowderCoating.Core.Entities.InventoryItem", "InventoryItem")
.WithMany()
.HasForeignKey("InventoryItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Customer");
b.Navigation("InventoryItem");
});
modelBuilder.Entity("PowderCoating.Core.Entities.Deposit", b => modelBuilder.Entity("PowderCoating.Core.Entities.Deposit", b =>
{ {
b.HasOne("PowderCoating.Core.Entities.Invoice", "AppliedToInvoice") b.HasOne("PowderCoating.Core.Entities.Invoice", "AppliedToInvoice")
@@ -70,6 +70,7 @@ public class UnitOfWork : IUnitOfWork
private IJobPhotoRepository? _jobPhotos; private IJobPhotoRepository? _jobPhotos;
private IRepository<JobNote>? _jobNotes; private IRepository<JobNote>? _jobNotes;
private IRepository<CustomerNote>? _customerNotes; private IRepository<CustomerNote>? _customerNotes;
private IRepository<CustomerPreferredPowder>? _customerPreferredPowders;
private IRepository<JobStatusHistory>? _jobStatusHistory; private IRepository<JobStatusHistory>? _jobStatusHistory;
private IRepository<PricingTier>? _pricingTiers; private IRepository<PricingTier>? _pricingTiers;
@@ -321,6 +322,8 @@ public class UnitOfWork : IUnitOfWork
/// <summary>Repository for <see cref="CustomerNote"/> free-text staff notes on customer records; tenant-filtered with soft delete.</summary> /// <summary>Repository for <see cref="CustomerNote"/> free-text staff notes on customer records; tenant-filtered with soft delete.</summary>
public IRepository<CustomerNote> CustomerNotes => public IRepository<CustomerNote> CustomerNotes =>
_customerNotes ??= new Repository<CustomerNote>(_context); _customerNotes ??= new Repository<CustomerNote>(_context);
public IRepository<CustomerPreferredPowder> CustomerPreferredPowders =>
_customerPreferredPowders ??= new Repository<CustomerPreferredPowder>(_context);
/// <summary>Repository for <see cref="JobStatusHistory"/> status-transition audit records; tenant-filtered with soft delete.</summary> /// <summary>Repository for <see cref="JobStatusHistory"/> status-transition audit records; tenant-filtered with soft delete.</summary>
public IRepository<JobStatusHistory> JobStatusHistory => public IRepository<JobStatusHistory> JobStatusHistory =>
@@ -94,7 +94,7 @@ public class NotificationService : INotificationService
quote.CompanyId, NotificationType.QuoteSent, values, quote.CompanyId, NotificationType.QuoteSent, values,
$"Your Quote {quote.QuoteNumber} from {companyName}"); $"Your Quote {quote.QuoteNumber} from {companyName}");
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, baseUrl); var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, baseUrl, replyToEmail);
var plainText = StripHtml(fullHtml); var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync( var (success, error) = await _emailService.SendEmailAsync(
@@ -137,7 +137,7 @@ public class NotificationService : INotificationService
quote.CompanyId, NotificationType.QuoteSent, values, quote.CompanyId, NotificationType.QuoteSent, values,
$"Your Quote {quote.QuoteNumber} from {companyName}"); $"Your Quote {quote.QuoteNumber} from {companyName}");
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, baseUrl); var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, baseUrl, replyToEmail);
var plainText = StripHtml(fullHtml); var plainText = StripHtml(fullHtml);
var (success, error, recipientsLog) = await SendToEmailListAsync( var (success, error, recipientsLog) = await SendToEmailListAsync(
@@ -300,7 +300,7 @@ public class NotificationService : INotificationService
quote.CompanyId, NotificationType.QuoteApproved, values, quote.CompanyId, NotificationType.QuoteApproved, values,
$"Quote {quote.QuoteNumber} Approved — {companyName}"); $"Quote {quote.QuoteNumber} Approved — {companyName}");
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync()); var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
var plainText = StripHtml(fullHtml); var plainText = StripHtml(fullHtml);
var (success, error, recipientsLog) = await SendToEmailListAsync( var (success, error, recipientsLog) = await SendToEmailListAsync(
@@ -383,7 +383,7 @@ public class NotificationService : INotificationService
var (subject, htmlBody) = await GetRenderedEmailAsync( var (subject, htmlBody) = await GetRenderedEmailAsync(
job.CompanyId, notifType, values, defaultSubject); job.CompanyId, notifType, values, defaultSubject);
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync()); var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
var plainText = StripHtml(fullHtml); var plainText = StripHtml(fullHtml);
var (success, error, recipientsLog) = await SendToEmailListAsync( var (success, error, recipientsLog) = await SendToEmailListAsync(
@@ -451,7 +451,7 @@ public class NotificationService : INotificationService
job.CompanyId, NotificationType.JobCompleted, values, job.CompanyId, NotificationType.JobCompleted, values,
$"Job {job.JobNumber} Complete — {companyName}"); $"Job {job.JobNumber} Complete — {companyName}");
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync()); var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
var plainText = StripHtml(fullHtml); var plainText = StripHtml(fullHtml);
var (success, error, recipientsLog) = await SendToEmailListAsync( var (success, error, recipientsLog) = await SendToEmailListAsync(
@@ -674,7 +674,7 @@ public class NotificationService : INotificationService
"""; """;
} }
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync()); var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
var plainText = !string.IsNullOrEmpty(paymentUrl) var plainText = !string.IsNullOrEmpty(paymentUrl)
? StripHtml(htmlBody) + $"\r\n\r\nPay online: {paymentUrl}" ? StripHtml(htmlBody) + $"\r\n\r\nPay online: {paymentUrl}"
: StripHtml(fullHtml); : StripHtml(fullHtml);
@@ -793,7 +793,7 @@ public class NotificationService : INotificationService
invoice.CompanyId, NotificationType.PaymentReceived, values, invoice.CompanyId, NotificationType.PaymentReceived, values,
$"Payment Received — Invoice {invoice.InvoiceNumber}"); $"Payment Received — Invoice {invoice.InvoiceNumber}");
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync()); var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
var plainText = StripHtml(fullHtml); var plainText = StripHtml(fullHtml);
var (success, error, recipientsLog) = await SendToEmailListAsync( var (success, error, recipientsLog) = await SendToEmailListAsync(
@@ -867,7 +867,7 @@ public class NotificationService : INotificationService
invoice.CompanyId, NotificationType.PaymentReminder, values, invoice.CompanyId, NotificationType.PaymentReminder, values,
$"Payment Reminder — Invoice {invoice.InvoiceNumber} ({daysOverdue} days overdue)"); $"Payment Reminder — Invoice {invoice.InvoiceNumber} ({daysOverdue} days overdue)");
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync()); var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
var plainText = StripHtml(fullHtml); var plainText = StripHtml(fullHtml);
var (success, error, recipientsLog) = await SendToEmailListAsync( var (success, error, recipientsLog) = await SendToEmailListAsync(
@@ -971,7 +971,7 @@ public class NotificationService : INotificationService
var (subject, htmlBody) = await GetRenderedEmailAsync( var (subject, htmlBody) = await GetRenderedEmailAsync(
quote.CompanyId, notificationType, values, defaultSubject); quote.CompanyId, notificationType, values, defaultSubject);
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, await GetBaseUrlAsync()); var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, await GetBaseUrlAsync(), replyToEmail);
var plainText = StripHtml(fullHtml); var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync( var (success, error) = await _emailService.SendEmailAsync(
@@ -1218,7 +1218,7 @@ public class NotificationService : INotificationService
var (custSubject, custHtml) = await GetRenderedEmailAsync( var (custSubject, custHtml) = await GetRenderedEmailAsync(
appointment.CompanyId, NotificationType.AppointmentReminder, customerValues, defaultSubject); appointment.CompanyId, NotificationType.AppointmentReminder, customerValues, defaultSubject);
var custFullHtml = AppendUnsubscribeFooterHtml(custHtml, customer.UnsubscribeToken, company, baseUrl); var custFullHtml = AppendUnsubscribeFooterHtml(custHtml, customer.UnsubscribeToken, company, baseUrl, replyToEmail);
var custPlainText = StripHtml(custFullHtml); var custPlainText = StripHtml(custFullHtml);
var (custOk, custErr, custLog) = await SendToEmailListAsync( var (custOk, custErr, custLog) = await SendToEmailListAsync(
@@ -1388,17 +1388,25 @@ public class NotificationService : INotificationService
/// <summary> /// <summary>
/// Appends CAN-SPAM required footer as HTML. /// Appends CAN-SPAM required footer as HTML.
/// </summary> /// </summary>
private static string AppendUnsubscribeFooterHtml(string htmlBody, string? token, Company? company = null, string? baseUrl = null) private static string AppendUnsubscribeFooterHtml(string htmlBody, string? token, Company? company = null, string? baseUrl = null, string? replyToEmail = null)
{ {
var hasUnsubscribeUrl = !string.IsNullOrEmpty(token) && !string.IsNullOrEmpty(baseUrl); var hasUnsubscribeUrl = !string.IsNullOrEmpty(token) && !string.IsNullOrEmpty(baseUrl);
var hasAddress = company != null && !string.IsNullOrWhiteSpace(company.Address); var hasAddress = company != null && !string.IsNullOrWhiteSpace(company.Address);
var hasReplyTo = !string.IsNullOrWhiteSpace(replyToEmail);
if (!hasUnsubscribeUrl && !hasAddress) if (!hasUnsubscribeUrl && !hasAddress && !hasReplyTo)
return htmlBody; return htmlBody;
var footer = "<hr style=\"border: none; border-top: 1px solid #eee; margin: 24px 0;\">" + var footer = "<hr style=\"border: none; border-top: 1px solid #eee; margin: 24px 0;\">" +
"<p style=\"font-size: 0.8em; color: #888; margin: 0;\">"; "<p style=\"font-size: 0.8em; color: #888; margin: 0;\">";
if (hasReplyTo)
{
var encodedEmail = WebUtility.HtmlEncode(replyToEmail!);
footer += $"Questions? Reply to this email or contact us at <a href=\"mailto:{encodedEmail}\" style=\"color: #888;\">{encodedEmail}</a>";
if (hasAddress || hasUnsubscribeUrl) footer += "<br>";
}
if (hasAddress) if (hasAddress)
{ {
var addressLine = BuildAddressLine(company!); var addressLine = BuildAddressLine(company!);
@@ -1535,7 +1543,15 @@ public class NotificationService : INotificationService
.AsNoTracking() .AsNoTracking()
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted); .FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
return (prefs?.EmailFromAddress, prefs?.EmailFromName); var email = prefs?.EmailFromAddress;
var name = prefs?.EmailFromName;
if (string.IsNullOrWhiteSpace(email))
_logger.LogWarning("No Reply-To email configured for company {CompanyId} — outgoing emails will show platform sender as reply address", companyId);
else
_logger.LogDebug("Reply-To for company {CompanyId}: {ReplyToEmail}", companyId, email);
return (email, name);
} }
/// <summary> /// <summary>
@@ -1726,6 +1726,26 @@ public class CompanySettingsController : Controller
#region Blast Setups #region Blast Setups
/// <summary>
/// Single authoritative blast-rate calculation endpoint. Takes equipment parameters and
/// returns the sqft/hr rate using the same ShopCapabilityCalculator formula the AI uses.
/// The modal live preview calls this instead of duplicating the formula in JavaScript.
/// </summary>
[HttpGet]
public IActionResult DeriveBlastRate(decimal cfm, int nozzle, int setupType, int substrate, decimal? rateOverride)
{
var setup = new CompanyBlastSetup
{
CompressorCfm = cfm,
BlastNozzleSize = nozzle,
SetupType = (BlastSetupType)setupType,
PrimarySubstrate = (BlastSubstrateType)substrate,
BlastRateSqFtPerHourOverride = rateOverride > 0 ? rateOverride : null
};
var rate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(setup);
return Json(new { rate });
}
/// <summary>Returns all active blast setups for the current company with their derived rates.</summary> /// <summary>Returns all active blast setups for the current company with their derived rates.</summary>
[HttpGet] [HttpGet]
public async Task<IActionResult> GetBlastSetups() public async Task<IActionResult> GetBlastSetups()
@@ -3051,6 +3071,15 @@ public class CompanySettingsController : Controller
var companyId = _tenantContext.GetCurrentCompanyId()!.Value; var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var templates = await _unitOfWork.CustomItemTemplates.FindAsync(t => t.CompanyId == companyId); var templates = await _unitOfWork.CustomItemTemplates.FindAsync(t => t.CompanyId == companyId);
// Parse FieldsJson into a real JsonElement so it is embedded as a proper JSON array
// in the export file rather than as an escaped string. This makes the file human-readable
// and avoids round-trip corruption when files are manually edited.
static System.Text.Json.JsonElement ParseFields(string? raw)
{
try { return System.Text.Json.JsonDocument.Parse(raw ?? "[]").RootElement.Clone(); }
catch { return System.Text.Json.JsonDocument.Parse("[]").RootElement.Clone(); }
}
var export = new var export = new
{ {
exportedAt = DateTime.UtcNow, exportedAt = DateTime.UtcNow,
@@ -3062,7 +3091,7 @@ public class CompanySettingsController : Controller
t.Name, t.Name,
t.Description, t.Description,
t.OutputMode, t.OutputMode,
t.FieldsJson, Fields = ParseFields(t.FieldsJson),
t.Formula, t.Formula,
t.DefaultRate, t.DefaultRate,
t.RateLabel, t.RateLabel,
@@ -3142,13 +3171,14 @@ public class CompanySettingsController : Controller
Name = name, Name = name,
Description = item.TryGetProperty("description", out var d) ? d.GetString() : null, Description = item.TryGetProperty("description", out var d) ? d.GetString() : null,
OutputMode = item.TryGetProperty("outputMode", out var om) ? om.GetString() ?? "FixedRate" : "FixedRate", OutputMode = item.TryGetProperty("outputMode", out var om) ? om.GetString() ?? "FixedRate" : "FixedRate",
FieldsJson = item.TryGetProperty("fieldsJson", out var fj) ? fj.GetString() ?? "[]" : "[]", // "fields" is a real JSON array in the export; GetRawText() reconstructs the string
FieldsJson = item.TryGetProperty("fields", out var fj) ? fj.GetRawText() : "[]",
Formula = item.TryGetProperty("formula", out var f) ? f.GetString() ?? "" : "", Formula = item.TryGetProperty("formula", out var f) ? f.GetString() ?? "" : "",
DefaultRate = item.TryGetProperty("defaultRate", out var dr) && dr.ValueKind == System.Text.Json.JsonValueKind.Number ? dr.GetDecimal() : null, DefaultRate = item.TryGetProperty("defaultRate", out var dr) && dr.ValueKind == System.Text.Json.JsonValueKind.Number ? dr.GetDecimal() : null,
RateLabel = item.TryGetProperty("rateLabel", out var rl) ? rl.GetString() : null, RateLabel = item.TryGetProperty("rateLabel", out var rl) ? rl.GetString() : null,
Notes = item.TryGetProperty("notes", out var n) ? n.GetString() : null, Notes = item.TryGetProperty("notes", out var n) ? n.GetString() : null,
DisplayOrder = item.TryGetProperty("displayOrder", out var dord) && dord.ValueKind == System.Text.Json.JsonValueKind.Number ? dord.GetInt32() : 0, DisplayOrder = item.TryGetProperty("displayOrder", out var dord) && dord.ValueKind == System.Text.Json.JsonValueKind.Number ? dord.GetInt32() : 0,
IsActive = item.TryGetProperty("isActive", out var ia) && ia.ValueKind == System.Text.Json.JsonValueKind.True, IsActive = true,
}; };
var fieldError = ValidateTemplateFields(dto.FieldsJson); var fieldError = ValidateTemplateFields(dto.FieldsJson);
@@ -144,9 +144,11 @@ public class CustomersController : Controller
} }
/// <summary> /// <summary>
/// Renders the customer detail page, including the 10 most-recent non-voided credit memos. /// Renders the customer detail page. In addition to basic info and credit memos, runs
/// Credit memos are loaded separately (not via eager loading) because the customer entity /// four sequential queries (jobs, quotes, invoices, deposits) to build:
/// does not navigate to CreditMemo; this keeps the Customer aggregate lean. /// (1) <see cref="CustomerLifetimeStatsDto"/> — aggregate KPIs for the stats card
/// (2) <see cref="CustomerTimelineEventDto"/> list — last 15 events for the activity feed
/// Credit memos are loaded separately because the Customer aggregate does not navigate to them.
/// </summary> /// </summary>
public async Task<IActionResult> Details(int? id) public async Task<IActionResult> Details(int? id)
{ {
@@ -170,6 +172,115 @@ public class CustomersController : Controller
.Take(10) .Take(10)
.ToList(); .ToList();
// CRM queries — must be sequential; EF Core's DbContext is not thread-safe
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CustomerId == id.Value && j.CompanyId == companyId, false, j => j.JobStatus)).ToList();
var quotes = (await _unitOfWork.Quotes.FindAsync(q => q.CustomerId == id.Value && q.CompanyId == companyId, false, q => q.QuoteStatus)).ToList();
var invoices = (await _unitOfWork.Invoices.FindAsync(i => i.CustomerId == id.Value && i.CompanyId == companyId)).ToList();
var deposits = (await _unitOfWork.Deposits.FindAsync(d => d.CustomerId == id.Value && d.CompanyId == companyId)).ToList();
var pendingPickups = (await _unitOfWork.Jobs.FindAsync(
j => j.CustomerId == id.Value && j.CompanyId == companyId
&& j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup,
false, j => j.JobStatus))
.OrderBy(j => j.UpdatedAt)
.ToList();
ViewBag.PendingPickups = pendingPickups;
var customerNotes = (await _unitOfWork.CustomerNotes.FindAsync(n => n.CustomerId == id.Value))
.OrderByDescending(n => n.CreatedAt)
.ToList();
ViewBag.CustomerNotes = customerNotes;
var preferredPowders = (await _unitOfWork.CustomerPreferredPowders.FindAsync(
p => p.CustomerId == id.Value, false, p => p.InventoryItem))
.ToList();
ViewBag.PreferredPowders = preferredPowders;
// Stats
var nonVoided = invoices.Where(i => i.Status != InvoiceStatus.Voided).ToList();
var stats = new CustomerLifetimeStatsDto
{
TotalJobs = jobs.Count,
ActiveJobs = jobs.Count(j => j.JobStatus != null && !j.JobStatus.IsTerminalStatus),
TotalRevenue = nonVoided.Sum(i => i.Total),
TotalCollected = nonVoided.Sum(i => i.AmountPaid),
AverageJobValue = jobs.Count > 0 ? jobs.Average(j => j.FinalPrice) : 0,
LastJobDate = jobs.Count > 0 ? jobs.Max(j => (DateTime?)j.CreatedAt) : null,
LastJobId = jobs.Count > 0 ? jobs.OrderByDescending(j => j.CreatedAt).First().Id : (int?)null,
TotalQuotes = quotes.Count,
TotalInvoices = invoices.Count,
OpenBalance = customer.CurrentBalance
};
stats.DaysSinceLastJob = stats.LastJobDate.HasValue
? (int)(DateTime.UtcNow - stats.LastJobDate.Value).TotalDays
: null;
// Timeline: merge all event types, sort descending, cap at 15
var events = new List<CustomerTimelineEventDto>();
foreach (var j in jobs)
events.Add(new CustomerTimelineEventDto
{
Date = j.CreatedAt,
Icon = "bi-briefcase",
BadgeColor = "primary",
Title = $"Job {j.JobNumber}",
Subtitle = j.Description,
Amount = j.FinalPrice > 0 ? j.FinalPrice : null,
EntityId = j.Id,
LinkController = "Jobs",
LinkAction = "Details"
});
foreach (var q in quotes)
events.Add(new CustomerTimelineEventDto
{
Date = q.QuoteDate,
Icon = "bi-file-text",
BadgeColor = "info",
Title = $"Quote {q.QuoteNumber}",
Subtitle = q.QuoteStatus?.DisplayName,
Amount = q.Total > 0 ? q.Total : null,
EntityId = q.Id,
LinkController = "Quotes",
LinkAction = "Details"
});
foreach (var inv in invoices)
events.Add(new CustomerTimelineEventDto
{
Date = inv.InvoiceDate,
Icon = inv.Status == InvoiceStatus.Paid ? "bi-receipt-cutoff" : "bi-receipt",
BadgeColor = inv.Status == InvoiceStatus.Paid ? "success" : "warning",
Title = $"Invoice {inv.InvoiceNumber}",
Subtitle = inv.Status.ToString(),
Amount = inv.Total,
EntityId = inv.Id,
LinkController = "Invoices",
LinkAction = "Details"
});
foreach (var dep in deposits)
events.Add(new CustomerTimelineEventDto
{
Date = dep.ReceivedDate,
Icon = "bi-cash-coin",
BadgeColor = "success",
Title = "Deposit received",
Subtitle = dep.ReceiptNumber,
Amount = dep.Amount,
EntityId = dep.JobId,
LinkController = dep.JobId.HasValue ? "Jobs" : null,
LinkAction = dep.JobId.HasValue ? "Details" : null
});
ViewBag.CrmStats = stats;
ViewBag.Timeline = events
.OrderByDescending(e => e.Date)
.Take(15)
.ToList();
var customerDto = _mapper.Map<CustomerDto>(customer); var customerDto = _mapper.Map<CustomerDto>(customer);
return View(customerDto); return View(customerDto);
} }
@@ -938,6 +1049,166 @@ public class CustomersController : Controller
return RedirectToAction(nameof(Details), new { id }); return RedirectToAction(nameof(Details), new { id });
} }
/// <summary>
/// Adds a quick internal note to the customer record. Returns the rendered note HTML so
/// the caller can prepend it to the notes list without a full page reload.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> AddCustomerNote(int id, string note, bool isImportant = false)
{
if (string.IsNullOrWhiteSpace(note))
return Json(new { success = false, message = "Note cannot be empty." });
try
{
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
if (customer == null) return Json(new { success = false, message = "Customer not found." });
var currentUser = await _userManager.GetUserAsync(User);
var entity = new PowderCoating.Core.Entities.CustomerNote
{
CustomerId = id,
Note = note.Trim(),
IsImportant = isImportant,
CreatedBy = currentUser?.Email
};
await _unitOfWork.CustomerNotes.AddAsync(entity);
await _unitOfWork.CompleteAsync();
var displayDate = entity.CreatedAt.ToLocalTime().ToString("MMM dd, yyyy h:mm tt");
var author = currentUser?.Email ?? "Staff";
var noteHtml = $@"<div class=""customer-note-item d-flex gap-2 py-2 border-bottom"" data-note-id=""{entity.Id}"">
<div class=""flex-grow-1"">
{(isImportant ? @"<span class=""text-warning me-1"" title=""Important"">&#9733;</span>" : "")}
<span class=""note-text"">{System.Web.HttpUtility.HtmlEncode(entity.Note)}</span>
<div class=""text-muted"" style=""font-size:0.75rem;"">{System.Web.HttpUtility.HtmlEncode(author)} &mdash; {displayDate}</div>
</div>
<button type=""button"" class=""btn btn-sm btn-link text-danger p-0 flex-shrink-0""
onclick=""deleteCustomerNote({id}, {entity.Id})"" title=""Delete note"">
<i class=""bi bi-trash""></i>
</button>
</div>";
return Json(new { success = true, noteHtml });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error adding note to customer {CustomerId}", id);
return Json(new { success = false, message = "An error occurred." });
}
}
/// <summary>
/// Soft-deletes a single customer note. Only the owning company can delete their own notes
/// (enforced via CompanyId on the entity + global query filter).
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteCustomerNote(int id, int noteId)
{
try
{
var note = await _unitOfWork.CustomerNotes.GetByIdAsync(noteId);
if (note == null || note.CustomerId != id)
return Json(new { success = false, message = "Note not found." });
await _unitOfWork.CustomerNotes.SoftDeleteAsync(note);
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting note {NoteId} for customer {CustomerId}", noteId, id);
return Json(new { success = false, message = "An error occurred." });
}
}
/// <summary>
/// Returns up to 10 inventory items matching the search term for the preferred-powder typeahead.
/// Results are scoped to the current company and only include active items.
/// </summary>
[HttpGet]
public async Task<IActionResult> SearchInventoryItems(string term)
{
if (string.IsNullOrWhiteSpace(term) || term.Length < 2)
return Json(Array.Empty<object>());
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var lower = term.ToLower();
var items = (await _unitOfWork.InventoryItems.FindAsync(
i => i.CompanyId == companyId && i.IsActive
&& (i.Name.ToLower().Contains(lower) || (i.SKU != null && i.SKU.ToLower().Contains(lower)))))
.OrderBy(i => i.Name)
.Take(10)
.Select(i => new { i.Id, i.Name, i.ColorName, sku = i.SKU })
.ToList();
return Json(items);
}
/// <summary>
/// Associates an inventory item as a preferred powder for a customer.
/// Silently succeeds if the association already exists (idempotent).
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> AddPreferredPowder(int id, int inventoryItemId, string? notes = null)
{
try
{
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
if (customer == null) return Json(new { success = false, message = "Customer not found." });
var item = await _unitOfWork.InventoryItems.GetByIdAsync(inventoryItemId);
if (item == null) return Json(new { success = false, message = "Inventory item not found." });
var existing = (await _unitOfWork.CustomerPreferredPowders.FindAsync(
p => p.CustomerId == id && p.InventoryItemId == inventoryItemId)).FirstOrDefault();
if (existing != null)
return Json(new { success = false, message = $"{item.Name} is already in preferred powders." });
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
await _unitOfWork.CustomerPreferredPowders.AddAsync(new PowderCoating.Core.Entities.CustomerPreferredPowder
{
CustomerId = id,
InventoryItemId = inventoryItemId,
Notes = notes?.Trim(),
CompanyId = companyId
});
await _unitOfWork.CompleteAsync();
return Json(new { success = true, itemId = inventoryItemId, itemName = item.Name, notes = notes?.Trim() });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error adding preferred powder for customer {CustomerId}", id);
return Json(new { success = false, message = "An error occurred." });
}
}
/// <summary>
/// Removes a preferred-powder association by inventory item ID. Soft-deletes the record
/// so the history is preserved but it no longer appears on the customer page.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> RemovePreferredPowder(int id, int itemId)
{
try
{
var record = (await _unitOfWork.CustomerPreferredPowders.FindAsync(
p => p.CustomerId == id && p.InventoryItemId == itemId)).FirstOrDefault();
if (record == null) return Json(new { success = false, message = "Record not found." });
await _unitOfWork.CustomerPreferredPowders.SoftDeleteAsync(record);
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error removing preferred powder {ItemId} for customer {CustomerId}", itemId, id);
return Json(new { success = false, message = "An error occurred." });
}
}
/// <summary> /// <summary>
/// Displays or downloads a dated activity statement for a customer. /// Displays or downloads a dated activity statement for a customer.
/// Pass <c>pdf=true</c> to download the QuestPDF version; otherwise renders the HTML view. /// Pass <c>pdf=true</c> to download the QuestPDF version; otherwise renders the HTML view.
@@ -1642,8 +1642,10 @@ public class InventoryController : Controller
var userId = _userManager.GetUserId(User); var userId = _userManager.GetUserId(User);
var recentCutoff = DateTime.UtcNow.AddDays(-7);
var myJobs = (await _unitOfWork.Jobs.FindAsync( var myJobs = (await _unitOfWork.Jobs.FindAsync(
j => !j.JobStatus.IsTerminalStatus && j.AssignedUserId == userId, j => (!j.JobStatus.IsTerminalStatus || j.UpdatedAt >= recentCutoff) && j.AssignedUserId == userId,
false, false,
j => j.Customer, j => j.Customer,
j => j.JobStatus)) j => j.JobStatus))
@@ -1651,7 +1653,7 @@ public class InventoryController : Controller
.Select(j => new ScanJobOption .Select(j => new ScanJobOption
{ {
Id = j.Id, Id = j.Id,
JobNumber = j.JobNumber, JobNumber = j.JobNumber + (j.JobStatus.IsTerminalStatus ? " (completed)" : ""),
CustomerName = j.Customer != null CustomerName = j.Customer != null
? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName) ? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName)
: "No Customer" : "No Customer"
@@ -1660,7 +1662,7 @@ public class InventoryController : Controller
var myJobIds = myJobs.Select(j => j.Id).ToHashSet(); var myJobIds = myJobs.Select(j => j.Id).ToHashSet();
var otherJobs = (await _unitOfWork.Jobs.FindAsync( var otherJobs = (await _unitOfWork.Jobs.FindAsync(
j => !j.JobStatus.IsTerminalStatus && !myJobIds.Contains(j.Id), j => (!j.JobStatus.IsTerminalStatus || j.UpdatedAt >= recentCutoff) && !myJobIds.Contains(j.Id),
false, false,
j => j.Customer, j => j.Customer,
j => j.JobStatus)) j => j.JobStatus))
@@ -1669,7 +1671,7 @@ public class InventoryController : Controller
.Select(j => new ScanJobOption .Select(j => new ScanJobOption
{ {
Id = j.Id, Id = j.Id,
JobNumber = j.JobNumber, JobNumber = j.JobNumber + (j.JobStatus.IsTerminalStatus ? " (completed)" : ""),
CustomerName = j.Customer != null CustomerName = j.Customer != null
? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName) ? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName)
: "No Customer" : "No Customer"
@@ -1686,9 +1688,64 @@ public class InventoryController : Controller
} }
/// <summary> /// <summary>
/// Records powder usage logged via the mobile scan page. Creates a JobUsage /// Core inventory usage recording logic shared by LogUsage (scan page) and LogMaterial (modal).
/// InventoryTransaction (and PowderUsageLog) when a job is selected, or an /// Deducts quantityUsed from QuantityOnHand, writes an InventoryTransaction, and posts GL entries.
/// Adjustment transaction when logging without a job. Updates QuantityOnHand. /// </summary>
private async Task<InventoryUsageResult> RecordInventoryUsageAsync(
int inventoryItemId, int? jobId, decimal quantityUsed,
InventoryTransactionType transactionType, string? notes)
{
var item = await _unitOfWork.InventoryItems.GetByIdAsync(inventoryItemId);
if (item == null)
return new InventoryUsageResult(false, "Inventory item not found.", 0, "", "");
string? reference = null;
if (jobId.HasValue)
{
var job = await _unitOfWork.Jobs.GetByIdAsync(jobId.Value);
reference = job != null ? $"Job {job.JobNumber}" : null;
}
item.QuantityOnHand -= quantityUsed;
item.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.InventoryItems.UpdateAsync(item);
var txn = new InventoryTransaction
{
InventoryItemId = item.Id,
TransactionType = transactionType,
Quantity = -quantityUsed,
UnitCost = item.UnitCost,
TotalCost = quantityUsed * item.UnitCost,
TransactionDate = DateTime.UtcNow,
BalanceAfter = item.QuantityOnHand,
JobId = jobId,
Reference = reference,
Notes = notes?.Trim(),
CompanyId = item.CompanyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.InventoryTransactions.AddAsync(txn);
await _unitOfWork.CompleteAsync();
if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue)
{
var cost = quantityUsed * (item.AverageCost > 0 ? item.AverageCost : item.UnitCost);
await _accountBalanceService.DebitAsync(item.CogsAccountId, cost);
await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
}
return new InventoryUsageResult(
true,
$"Logged {quantityUsed:N2} {item.UnitOfMeasure} of {item.Name}. New balance: {item.QuantityOnHand:N2} {item.UnitOfMeasure}.",
item.QuantityOnHand,
item.UnitOfMeasure,
item.Name);
}
/// <summary>
/// Records powder usage from the mobile scan page. Resolves the used quantity
/// (caller already converts "remaining weight" to delta before posting) and redirects to ScanSuccess.
/// </summary> /// </summary>
[HttpPost] [HttpPost]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
@@ -1697,55 +1754,26 @@ public class InventoryController : Controller
{ {
try try
{ {
var item = await _unitOfWork.InventoryItems.GetByIdAsync(inventoryItemId);
if (item == null) return NotFound();
if (quantity <= 0) if (quantity <= 0)
{ {
TempData["ScanError"] = "Quantity must be greater than zero."; TempData["ScanError"] = "Quantity must be greater than zero.";
return RedirectToAction(nameof(Scan), new { id = inventoryItemId }); return RedirectToAction(nameof(Scan), new { id = inventoryItemId });
} }
var userId = _userManager.GetUserId(User) ?? string.Empty; var result = await RecordInventoryUsageAsync(
// Scan-based logging always records as JobUsage; Adjustment is for manual stock corrections only inventoryItemId, jobId, quantity,
var txnType = InventoryTransactionType.JobUsage; InventoryTransactionType.JobUsage, notes);
item.QuantityOnHand -= quantity; if (!result.Success)
item.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.InventoryItems.UpdateAsync(item);
var txn = new InventoryTransaction
{ {
InventoryItemId = item.Id, TempData["ScanError"] = result.Message;
TransactionType = txnType, return RedirectToAction(nameof(Scan), new { id = inventoryItemId });
Quantity = -quantity,
UnitCost = item.UnitCost,
TotalCost = quantity * item.UnitCost,
TransactionDate = DateTime.UtcNow,
BalanceAfter = item.QuantityOnHand,
JobId = jobId,
Reference = jobId.HasValue ? $"Job #{jobId}" : null,
Notes = notes?.Trim()
};
await _unitOfWork.InventoryTransactions.AddAsync(txn);
await _unitOfWork.SaveChangesAsync();
// GL: DR COGS, CR Inventory Asset — no-op if accounts not configured on the item
if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue)
{
var cost = quantity * (item.AverageCost > 0 ? item.AverageCost : item.UnitCost);
await _accountBalanceService.DebitAsync(item.CogsAccountId, cost);
await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
} }
// PowderUsageLog requires a specific JobItem + Coat FK — scan-based logging TempData["ScanSuccess"] = result.Message;
// doesn't have that context, so we rely on the InventoryTransaction alone
// for the audit trail. Coat-level PowderUsageLogs are created by the job workflow.
TempData["ScanSuccess"] = $"Logged {quantity:N2} {item.UnitOfMeasure} of {item.Name}. New balance: {item.QuantityOnHand:N2} {item.UnitOfMeasure}.";
TempData["ScanItemId"] = inventoryItemId.ToString(); TempData["ScanItemId"] = inventoryItemId.ToString();
TempData["ScanJobId"] = jobId?.ToString(); TempData["ScanJobId"] = jobId?.ToString();
TempData["ScanItemName"] = item.Name; TempData["ScanItemName"] = result.ItemName;
return RedirectToAction(nameof(ScanSuccess)); return RedirectToAction(nameof(ScanSuccess));
} }
catch (Exception ex) catch (Exception ex)
@@ -1756,6 +1784,43 @@ public class InventoryController : Controller
} }
} }
/// <summary>
/// Records manual material usage from the job details modal. Accepts JSON, resolves
/// the amount used (caller sends the already-computed used quantity), and returns JSON
/// so the modal can close and refresh inline.
/// </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 txnType = req.TransactionType == "Waste"
? InventoryTransactionType.Waste
: InventoryTransactionType.JobUsage;
var result = await RecordInventoryUsageAsync(
req.InventoryItemId, req.JobId, req.QuantityUsed, txnType, req.Notes);
return Json(new
{
success = result.Success,
message = result.Message,
newBalance = result.NewBalance,
unitOfMeasure = result.UnitOfMeasure,
itemName = result.ItemName
});
}
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." });
}
}
/// <summary> /// <summary>
/// Success screen shown after a usage log is saved. Offers "Log Another Item for /// Success screen shown after a usage log is saved. Offers "Log Another Item for
/// This Job" and "Done" options. /// This Job" and "Done" options.
@@ -2003,7 +2068,7 @@ public class InventoryController : Controller
/// <summary> /// <summary>
/// Returns the current values of a JobUsage InventoryTransaction plus a list of active /// Returns the current values of a JobUsage InventoryTransaction plus a list of active
/// jobs so the edit modal can be pre-populated without a full page reload. /// jobs (plus the currently assigned job even if terminal) for the edit modal.
/// </summary> /// </summary>
[HttpGet] [HttpGet]
public async Task<IActionResult> GetUsageForEdit(int id) public async Task<IActionResult> GetUsageForEdit(int id)
@@ -2034,10 +2099,27 @@ public class InventoryController : Controller
}) })
.ToList(); .ToList();
// If the assigned job has terminal status it won't appear in the active list; insert it at the top
// so the dropdown pre-selects correctly and the user can see the existing job assignment.
if (txn.JobId.HasValue && jobs.All(j => j.Id != txn.JobId.Value))
{
var assignedJob = await _unitOfWork.Jobs.GetByIdAsync(txn.JobId.Value, false, j => j.Customer);
if (assignedJob != null)
jobs.Insert(0, new ScanJobOption
{
Id = assignedJob.Id,
JobNumber = assignedJob.JobNumber,
CustomerName = assignedJob.Customer != null
? (assignedJob.Customer.CompanyName ?? $"{assignedJob.Customer.ContactFirstName} {assignedJob.Customer.ContactLastName}".Trim())
: "No Customer"
});
}
return Json(new return Json(new
{ {
transactionId = txn.Id, transactionId = txn.Id,
jobId = txn.JobId, jobId = txn.JobId,
quantity = Math.Abs(txn.Quantity),
notes = txn.Notes, notes = txn.Notes,
transactionDate = txn.TransactionDate.ToString("yyyy-MM-ddTHH:mm"), transactionDate = txn.TransactionDate.ToString("yyyy-MM-ddTHH:mm"),
itemName = txn.InventoryItem?.Name, itemName = txn.InventoryItem?.Name,
@@ -2046,14 +2128,15 @@ public class InventoryController : Controller
} }
/// <summary> /// <summary>
/// Saves edits to a JobUsage InventoryTransaction's job assignment, notes, and date. /// Saves edits to a JobUsage InventoryTransaction: job assignment, quantity, notes, and date.
/// Quantity and balance are not changed. /// When quantity changes the InventoryItem.QuantityOnHand is adjusted by the delta so the
/// ledger balance remains consistent.
/// </summary> /// </summary>
[HttpPost] [HttpPost]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> EditUsageTransaction(int id, int? jobId, string? notes, DateTime transactionDate) public async Task<IActionResult> EditUsageTransaction(int id, int? jobId, string? notes, DateTime transactionDate, decimal? quantity)
{ {
var txn = await _unitOfWork.InventoryTransactions.GetByIdAsync(id); var txn = await _unitOfWork.InventoryTransactions.GetByIdAsync(id, false, t => t.InventoryItem);
if (txn == null) return NotFound(); if (txn == null) return NotFound();
if (txn.TransactionType != InventoryTransactionType.JobUsage if (txn.TransactionType != InventoryTransactionType.JobUsage
&& txn.TransactionType != InventoryTransactionType.Adjustment) && txn.TransactionType != InventoryTransactionType.Adjustment)
@@ -2075,6 +2158,28 @@ public class InventoryController : Controller
if (jobId.HasValue && txn.TransactionType == InventoryTransactionType.Adjustment) if (jobId.HasValue && txn.TransactionType == InventoryTransactionType.Adjustment)
txn.TransactionType = InventoryTransactionType.JobUsage; txn.TransactionType = InventoryTransactionType.JobUsage;
// Adjust inventory when the logged quantity is changed.
// txn.Quantity is stored as a negative number for usage (e.g. -3.5 for 3.5 lbs used).
if (quantity.HasValue && quantity.Value > 0)
{
var oldUsed = Math.Abs(txn.Quantity);
var newUsed = quantity.Value;
if (oldUsed != newUsed)
{
var item = txn.InventoryItem ?? await _unitOfWork.InventoryItems.GetByIdAsync(txn.InventoryItemId);
if (item != null)
{
// Positive delta means less was actually used → restore the difference to inventory.
item.QuantityOnHand += oldUsed - newUsed;
item.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.InventoryItems.UpdateAsync(item);
txn.BalanceAfter = item.QuantityOnHand;
}
txn.Quantity = -newUsed;
txn.TotalCost = newUsed * txn.UnitCost;
}
}
txn.Notes = notes?.Trim(); txn.Notes = notes?.Trim();
txn.TransactionDate = transactionDate.Kind == DateTimeKind.Utc txn.TransactionDate = transactionDate.Kind == DateTimeKind.Utc
? transactionDate : DateTime.SpecifyKind(transactionDate, DateTimeKind.Utc); ? transactionDate : DateTime.SpecifyKind(transactionDate, DateTimeKind.Utc);
@@ -2094,3 +2199,21 @@ public class ScanJobOption
public string JobNumber { get; set; } = string.Empty; public string JobNumber { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty; public string CustomerName { get; set; } = string.Empty;
} }
/// <summary>Result returned by RecordInventoryUsageAsync.</summary>
public record InventoryUsageResult(
bool Success,
string Message,
decimal NewBalance,
string UnitOfMeasure,
string ItemName);
/// <summary>JSON body for the LogMaterial endpoint (job details modal).</summary>
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; }
}
@@ -372,6 +372,7 @@ public class InvoicesController : Controller
dto.JobId = job.Id; dto.JobId = job.Id;
dto.CustomerId = job.CustomerId; dto.CustomerId = job.CustomerId;
dto.CustomerPO = job.CustomerPO; dto.CustomerPO = job.CustomerPO;
dto.ProjectName = job.ProjectName;
// Resolve catalog item revenue accounts for pre-population // Resolve catalog item revenue accounts for pre-population
var catalogItemIds = job.JobItems var catalogItemIds = job.JobItems
@@ -710,6 +711,7 @@ public class InvoicesController : Controller
InternalNotes = dto.InternalNotes, InternalNotes = dto.InternalNotes,
Terms = dto.Terms, Terms = dto.Terms,
CustomerPO = dto.CustomerPO, CustomerPO = dto.CustomerPO,
ProjectName = dto.ProjectName,
CompanyId = currentUser.CompanyId, CompanyId = currentUser.CompanyId,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
CreatedBy = currentUser.Email CreatedBy = currentUser.Email
@@ -901,6 +903,7 @@ public class InvoicesController : Controller
InternalNotes = invoice.InternalNotes, InternalNotes = invoice.InternalNotes,
Terms = invoice.Terms, Terms = invoice.Terms,
CustomerPO = invoice.CustomerPO, CustomerPO = invoice.CustomerPO,
ProjectName = invoice.ProjectName ?? invoice.Job?.ProjectName,
InvoiceItems = invoice.InvoiceItems InvoiceItems = invoice.InvoiceItems
.Where(i => !i.IsDeleted) .Where(i => !i.IsDeleted)
.OrderBy(i => i.DisplayOrder) .OrderBy(i => i.DisplayOrder)
@@ -1036,6 +1039,7 @@ public class InvoicesController : Controller
invoice.InternalNotes = dto.InternalNotes; invoice.InternalNotes = dto.InternalNotes;
invoice.Terms = dto.Terms; invoice.Terms = dto.Terms;
invoice.CustomerPO = dto.CustomerPO; invoice.CustomerPO = dto.CustomerPO;
invoice.ProjectName = dto.ProjectName;
invoice.UpdatedAt = DateTime.UtcNow; invoice.UpdatedAt = DateTime.UtcNow;
invoice.UpdatedBy = currentUser?.Email; invoice.UpdatedBy = currentUser?.Email;
@@ -1981,6 +1981,146 @@ public class JobsController : Controller
} }
/// <summary> /// <summary>
/// <summary>
/// Creates a new job that is a copy of an existing job. All items, coats, and prep services
/// are deep-copied. Pricing-routing flags (IsAiItem, IsGenericItem, IsLaborItem, IsSalesItem)
/// are preserved so pricing behaves identically. Dates, worker assignment, and invoice links
/// are cleared; status resets to Pending so the job enters the normal workflow from the start.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
public async Task<IActionResult> CloneJob(int id)
{
try
{
var source = await _unitOfWork.Jobs.LoadForDetailsAsync(id);
if (source == null) return NotFound();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var pendingStatus = await _unitOfWork.JobStatusLookups.FirstOrDefaultAsync(
s => s.StatusCode == AppConstants.StatusCodes.Job.Pending && s.CompanyId == companyId);
if (pendingStatus == null)
{
this.ToastError("Could not find Pending status for this company.");
return RedirectToAction(nameof(Details), new { id });
}
var newJob = new Job
{
JobNumber = await GenerateJobNumber(),
CustomerId = source.CustomerId,
CompanyId = companyId,
JobStatusId = pendingStatus.Id,
JobPriorityId = source.JobPriorityId,
Description = source.Description,
CustomerPO = source.CustomerPO,
ProjectName = source.ProjectName,
SpecialInstructions = source.SpecialInstructions,
InternalNotes = source.InternalNotes,
Tags = source.Tags,
IsRushJob = source.IsRushJob,
RequiresCustomerApproval = source.RequiresCustomerApproval,
DiscountType = source.DiscountType,
DiscountValue = source.DiscountValue,
DiscountReason = source.DiscountReason,
OvenCostId = source.OvenCostId,
OvenBatches = source.OvenBatches,
OvenCycleMinutes = source.OvenCycleMinutes,
ShopSuppliesPercent = source.ShopSuppliesPercent,
ShopAccessCode = Guid.NewGuid()
};
await _unitOfWork.Jobs.AddAsync(newJob);
await _unitOfWork.CompleteAsync();
foreach (var srcItem in source.JobItems.Where(i => !i.IsDeleted))
{
var newItem = new JobItem
{
JobId = newJob.Id,
CompanyId = companyId,
Description = srcItem.Description,
Quantity = srcItem.Quantity,
ColorName = srcItem.ColorName,
ColorCode = srcItem.ColorCode,
Finish = srcItem.Finish,
SurfaceArea = srcItem.SurfaceArea,
SurfaceAreaSqFt = srcItem.SurfaceAreaSqFt,
CatalogItemId = srcItem.CatalogItemId,
UnitPrice = srcItem.UnitPrice,
TotalPrice = srcItem.TotalPrice,
LaborCost = srcItem.LaborCost,
IsGenericItem = srcItem.IsGenericItem,
ManualUnitPrice = srcItem.ManualUnitPrice,
PowderCostOverride = srcItem.PowderCostOverride,
IsLaborItem = srcItem.IsLaborItem,
IsSalesItem = srcItem.IsSalesItem,
IsAiItem = srcItem.IsAiItem,
AiTags = srcItem.AiTags,
IsCustomFormulaItem = srcItem.IsCustomFormulaItem,
CustomItemTemplateId = srcItem.CustomItemTemplateId,
FormulaFieldValuesJson = srcItem.FormulaFieldValuesJson,
Sku = srcItem.Sku,
IncludePrepCost = srcItem.IncludePrepCost,
RequiresSandblasting = srcItem.RequiresSandblasting,
RequiresMasking = srcItem.RequiresMasking,
EstimatedMinutes = srcItem.EstimatedMinutes,
Complexity = srcItem.Complexity,
Notes = srcItem.Notes
// AiPredictionId intentionally not copied — prediction belongs to original quote
};
await _unitOfWork.JobItems.AddAsync(newItem);
await _unitOfWork.CompleteAsync();
foreach (var srcCoat in srcItem.Coats.Where(c => !c.IsDeleted))
{
await _unitOfWork.JobItemCoats.AddAsync(new JobItemCoat
{
JobItemId = newItem.Id,
CompanyId = companyId,
CoatName = srcCoat.CoatName,
Sequence = srcCoat.Sequence,
InventoryItemId = srcCoat.InventoryItemId,
ColorName = srcCoat.ColorName,
VendorId = srcCoat.VendorId,
ColorCode = srcCoat.ColorCode,
Finish = srcCoat.Finish,
CoverageSqFtPerLb = srcCoat.CoverageSqFtPerLb,
TransferEfficiency = srcCoat.TransferEfficiency,
PowderCostPerLb = srcCoat.PowderCostPerLb,
PowderToOrder = srcCoat.PowderToOrder,
NoExtraLayerCharge = srcCoat.NoExtraLayerCharge,
Notes = srcCoat.Notes
// Powder ordering / receiving tracking fields intentionally not copied
});
}
foreach (var srcPrep in srcItem.PrepServices.Where(p => !p.IsDeleted))
{
await _unitOfWork.JobItemPrepServices.AddAsync(new JobItemPrepService
{
JobItemId = newItem.Id,
CompanyId = companyId,
PrepServiceId = srcPrep.PrepServiceId,
EstimatedMinutes = srcPrep.EstimatedMinutes,
BlastSetupId = srcPrep.BlastSetupId
});
}
}
await _unitOfWork.CompleteAsync();
this.ToastSuccess($"Job cloned as {newJob.JobNumber} &mdash; review and update dates before scheduling.");
return RedirectToAction(nameof(Details), new { id = newJob.Id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error cloning job {JobId}", id);
this.ToastError("An error occurred while cloning the job.");
return RedirectToAction(nameof(Details), new { id });
}
}
/// Generates the next sequential job number in the format PREFIX-YYMM-#### (e.g. JOB-2404-0001). /// Generates the next sequential job number in the format PREFIX-YYMM-#### (e.g. JOB-2404-0001).
/// Uses IgnoreQueryFilters so soft-deleted jobs are included in the sequence counter — /// Uses IgnoreQueryFilters so soft-deleted jobs are included in the sequence counter —
/// this prevents number reuse if a job is deleted after being created this month. /// this prevents number reuse if a job is deleted after being created this month.
@@ -4399,75 +4539,7 @@ 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> // LogMaterial has been consolidated into InventoryController.LogMaterial.
/// 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." });
}
}
/// <summary> /// <summary>
/// Inline-edits description, quantity, and unit price on a single job line item. /// Inline-edits description, quantity, and unit price on a single job line item.
@@ -4554,14 +4626,6 @@ public class PatchJobItemRequest
public decimal Quantity { get; set; } public decimal Quantity { get; set; }
public decimal UnitPrice { get; set; } public decimal UnitPrice { 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 class CreateReworkJobRequest
{ {
public int ReworkRecordId { get; set; } public int ReworkRecordId { get; set; }
@@ -1957,12 +1957,10 @@ public class QuotesController : Controller
if (dto.SmsConsent) if (dto.SmsConsent)
await _notificationService.NotifySmsConsentGrantedAsync(customer); await _notificationService.NotifySmsConsentGrantedAsync(customer);
// Get "Converted" status (cached) // Update quote to link to new customer.
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; // Do NOT set "Converted" status here — that status is reserved for when a job is
var statuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId); // actually created via CreateJobFromQuote. Keeping the quote at "Approved" lets the
var convertedStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Converted); // user immediately click "Create Job from Quote" on the next screen.
// Update quote to link to new customer
quote.CustomerId = customer.Id; quote.CustomerId = customer.Id;
// Clear prospect fields // Clear prospect fields
@@ -1977,14 +1975,11 @@ public class QuotesController : Controller
quote.ProspectSmsConsent = false; quote.ProspectSmsConsent = false;
quote.ProspectSmsConsentedAt = null; quote.ProspectSmsConsentedAt = null;
// Update status to converted
quote.QuoteStatusId = convertedStatus?.Id ?? quote.QuoteStatusId;
await _unitOfWork.Quotes.UpdateAsync(quote); await _unitOfWork.Quotes.UpdateAsync(quote);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
this.ToastSuccess($"Prospect/Walk-In successfully converted to customer! Quote {quote.QuoteNumber} has been updated."); this.ToastSuccess($"Customer record created! You can now create a job from quote {quote.QuoteNumber}.");
return RedirectToAction("Details", "Customers", new { id = customer.Id }); return RedirectToAction(nameof(Details), new { id = dto.QuoteId });
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -2958,6 +2953,7 @@ public class QuotesController : Controller
Total = quote.Total Total = quote.Total
}), }),
CustomerPO = quote.CustomerPO, CustomerPO = quote.CustomerPO,
ProjectName = quote.ProjectName,
InternalNotes = quote.Notes, // Copy internal notes from quote InternalNotes = quote.Notes, // Copy internal notes from quote
IsCustomerApproved = true, IsCustomerApproved = true,
IsRushJob = quote.IsRushJob, IsRushJob = quote.IsRushJob,
@@ -3435,13 +3431,21 @@ public class QuotesController : Controller
// Build company AI context: profile text + recent accepted predictions as few-shot examples // Build company AI context: profile text + recent accepted predictions as few-shot examples
var aiContext = await BuildCompanyAiContextAsync(companyId, costs); var aiContext = await BuildCompanyAiContextAsync(companyId, costs);
// Load the specific blast setup when the user picked one before analyzing // Load the specific blast setup when the user picked one before analyzing.
// If none was explicitly chosen, fall back to the company's default blast setup so
// named-setup rates (e.g. a blast cabinet configured at 82 sqft/hr) are always
// used instead of the coarser company-level operating cost fallback.
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 && b.CompanyId == companyId); var setups = await _unitOfWork.BlastSetups.FindAsync(b => b.Id == request.BlastSetupId.Value && b.IsActive && b.CompanyId == companyId);
selectedBlastSetup = setups.FirstOrDefault(); selectedBlastSetup = setups.FirstOrDefault();
} }
else
{
var defaultSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsDefault && b.IsActive && b.CompanyId == companyId);
selectedBlastSetup = defaultSetups.FirstOrDefault();
}
var result = await _aiService.AnalyzeItemAsync(request, photos, costs, avgPowderCost, aiContext, selectedBlastSetup); var result = await _aiService.AnalyzeItemAsync(request, photos, costs, avgPowderCost, aiContext, selectedBlastSetup);
await _usageLogger.LogAsync(companyId, user?.Id ?? "", AppConstants.AiFeatures.PhotoQuote, result.Success, photos.Sum(p => p.Data.Length)); await _usageLogger.LogAsync(companyId, user?.Id ?? "", AppConstants.AiFeatures.PhotoQuote, result.Success, photos.Sum(p => p.Data.Length));
@@ -74,7 +74,7 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label asp-for="Email" class="form-label">Email <span class="text-danger">*</span> <span class="text-muted fw-normal">(required if no phone number)</span></label> <label asp-for="Email" class="form-label">Email <span class="text-danger">*</span> <span class="text-muted fw-normal">(required if no phone number)</span></label>
<input asp-for="Email" type="email" multiple class="form-control" placeholder="name@example.com (comma-separate multiple)" /> <input asp-for="Email" type="text" class="form-control" placeholder="name@example.com (comma-separate multiple)" />
<span asp-validation-for="Email" class="text-danger"></span> <span asp-validation-for="Email" class="text-danger"></span>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
@@ -91,7 +91,7 @@
<label asp-for="BillingEmail" class="form-label">Billing / Accounting Email <label asp-for="BillingEmail" class="form-label">Billing / Accounting Email
<span class="text-muted fw-normal">(invoices sent here)</span> <span class="text-muted fw-normal">(invoices sent here)</span>
</label> </label>
<input asp-for="BillingEmail" type="email" multiple class="form-control" placeholder="accounting@company.com (comma-separate multiple)" /> <input asp-for="BillingEmail" type="text" class="form-control" placeholder="accounting@company.com (comma-separate multiple)" />
<span asp-validation-for="BillingEmail" class="text-danger"></span> <span asp-validation-for="BillingEmail" class="text-danger"></span>
<div class="form-text">When set, invoices are emailed here instead of the contact email.</div> <div class="form-text">When set, invoices are emailed here instead of the contact email.</div>
</div> </div>
@@ -328,6 +328,121 @@
</div> </div>
</div> </div>
} }
<!-- Customer Notes -->
@{
var customerNotes = ViewBag.CustomerNotes as List<PowderCoating.Core.Entities.CustomerNote>;
}
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-sticky me-2 text-primary"></i>Internal Notes
</h5>
</div>
<div class="card-body p-0">
<div id="customer-notes-list">
@if (customerNotes != null && customerNotes.Count > 0)
{
@foreach (var note in customerNotes)
{
<div class="customer-note-item d-flex gap-2 px-3 py-2 border-bottom" data-note-id="@note.Id">
<div class="flex-grow-1">
@if (note.IsImportant)
{
<span class="text-warning me-1" title="Important">&#9733;</span>
}
<span class="note-text small">@note.Note</span>
<div class="text-muted" style="font-size:0.75rem;">
@(note.CreatedBy ?? "Staff") &mdash; @note.CreatedAt.Tz(ViewBag.CompanyTimeZone as string).ToString("MMM dd, yyyy h:mm tt")
</div>
</div>
<button type="button" class="btn btn-sm btn-link text-danger p-0 flex-shrink-0 align-self-start"
onclick="deleteCustomerNote(@Model.Id, @note.Id)" title="Delete note">
<i class="bi bi-trash"></i>
</button>
</div>
}
}
else
{
<div id="no-notes-placeholder" class="px-3 py-2 text-muted small">No notes yet.</div>
}
</div>
<div class="px-3 py-3 border-top bg-light">
<div class="mb-2">
<textarea id="newNoteText" class="form-control form-control-sm" rows="2"
placeholder="Add an internal note..." maxlength="2000"></textarea>
</div>
<div class="d-flex justify-content-between align-items-center">
<div class="form-check form-check-sm mb-0">
<input class="form-check-input" type="checkbox" id="newNoteImportant">
<label class="form-check-label small" for="newNoteImportant">
<span class="text-warning">&#9733;</span> Mark important
</label>
</div>
<button type="button" class="btn btn-sm btn-primary" onclick="addCustomerNote(@Model.Id)">
<i class="bi bi-plus-circle me-1"></i>Add Note
</button>
</div>
</div>
</div>
</div>
<!-- Recent Activity Timeline -->
@{
var timeline = ViewBag.Timeline as List<PowderCoating.Application.DTOs.Customer.CustomerTimelineEventDto>;
}
@if (timeline != null && timeline.Count > 0)
{
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-clock-history me-2 text-primary"></i>Recent Activity
</h5>
<a asp-action="Activity" asp-route-id="@Model.Id" class="btn btn-sm btn-outline-secondary">
View All
</a>
</div>
<div class="card-body p-0">
@foreach (var ev in timeline)
{
var hasLink = ev.LinkController != null && ev.EntityId.HasValue;
var rowTag = hasLink ? "a" : "div";
var href = hasLink
? Url.Action(ev.LinkAction, ev.LinkController, new { id = ev.EntityId })
: null;
<div class="d-flex align-items-start gap-3 px-3 py-3 border-bottom @(hasLink ? "timeline-row" : "")">
<div class="rounded-circle d-flex align-items-center justify-content-center flex-shrink-0 mt-1"
style="width:34px;height:34px;background:var(--bs-@(ev.BadgeColor)-bg-subtle,#f0f0f0);">
<i class="bi @ev.Icon text-@ev.BadgeColor" style="font-size:0.9rem;"></i>
</div>
<div class="flex-grow-1 min-width-0">
@if (hasLink)
{
<a asp-controller="@ev.LinkController" asp-action="@ev.LinkAction" asp-route-id="@ev.EntityId"
class="fw-semibold text-decoration-none text-body d-block text-truncate">@ev.Title</a>
}
else
{
<span class="fw-semibold d-block text-truncate">@ev.Title</span>
}
@if (!string.IsNullOrEmpty(ev.Subtitle))
{
<span class="text-muted small d-block text-truncate">@ev.Subtitle</span>
}
<span class="text-muted" style="font-size:0.75rem;">@ev.Date.Tz(ViewBag.CompanyTimeZone as string).ToString("MMM dd, yyyy")</span>
</div>
@if (ev.Amount.HasValue)
{
<div class="text-end flex-shrink-0">
<span class="fw-semibold small">@ev.Amount.Value.ToString("C")</span>
</div>
}
</div>
}
</div>
</div>
}
</div> </div>
<!-- Right Column - Statistics --> <!-- Right Column - Statistics -->
@@ -378,6 +493,41 @@
</div> </div>
</div> </div>
<!-- Outstanding Pickups -->
@{
var pendingPickups = ViewBag.PendingPickups as List<PowderCoating.Core.Entities.Job>;
}
@if (pendingPickups != null && pendingPickups.Count > 0)
{
<div class="card border-0 shadow-sm mb-4 border-warning border-opacity-50">
<div class="card-header bg-warning bg-opacity-10 border-0 py-3">
<h5 class="mb-0 fw-semibold text-warning-emphasis">
<i class="bi bi-truck me-2"></i>Ready for Pickup
<span class="badge bg-warning text-dark ms-2">@pendingPickups.Count</span>
</h5>
</div>
<div class="card-body p-0">
@foreach (var pickup in pendingPickups)
{
var daysWaiting = (int)(DateTime.UtcNow - (pickup.UpdatedAt ?? pickup.CreatedAt)).TotalDays;
<div class="d-flex align-items-center gap-2 px-3 py-2 border-bottom">
<div class="flex-grow-1">
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@pickup.Id"
class="fw-semibold text-decoration-none small">@pickup.JobNumber</a>
@if (!string.IsNullOrEmpty(pickup.Description))
{
<div class="text-muted text-truncate" style="font-size:0.75rem;max-width:160px;">@pickup.Description</div>
}
</div>
<span class="badge @(daysWaiting >= 7 ? "bg-danger" : daysWaiting >= 3 ? "bg-warning text-dark" : "bg-success")">
@(daysWaiting == 0 ? "Today" : $"{daysWaiting}d waiting")
</span>
</div>
}
</div>
</div>
}
<!-- Store Credit History --> <!-- Store Credit History -->
@{ @{
var creditMemos = ViewBag.CreditMemos as List<PowderCoating.Core.Entities.CreditMemo>; var creditMemos = ViewBag.CreditMemos as List<PowderCoating.Core.Entities.CreditMemo>;
@@ -430,33 +580,146 @@
</div> </div>
} }
<!-- Activity --> <!-- Customer Stats -->
@{
var crmStats = ViewBag.CrmStats as PowderCoating.Application.DTOs.Customer.CustomerLifetimeStatsDto;
}
@if (crmStats != null)
{
<div class="card border-0 shadow-sm mb-4"> <div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3"> <div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold"> <h5 class="mb-0 fw-semibold">
<i class="bi bi-clock-history me-2 text-primary"></i>Activity <i class="bi bi-bar-chart-line me-2 text-primary"></i>Customer Stats
</h5> </h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="mb-3"> <!-- Jobs row -->
<label class="text-muted small mb-1">Last Contact</label> <div class="row g-2 mb-3">
<p class="mb-0"> <div class="col-6 text-center p-2" style="border-right:1px solid #dee2e6;">
@if (Model.LastContactDate.HasValue) <div class="text-muted small mb-1">Total Jobs</div>
<div class="fs-4 fw-bold text-primary">@crmStats.TotalJobs</div>
@if (crmStats.ActiveJobs > 0)
{ {
<span>@Model.LastContactDate.Value.ToString("MMMM dd, yyyy")</span> <span class="badge bg-success bg-opacity-10 text-success" style="font-size:0.7rem;">
@crmStats.ActiveJobs active
</span>
}
</div>
<div class="col-6 text-center p-2">
<div class="text-muted small mb-1">Avg Job Value</div>
<div class="fs-4 fw-bold">@crmStats.AverageJobValue.ToString("C0")</div>
</div>
</div>
<hr class="my-2" />
<!-- Revenue row -->
<div class="row g-2 mb-2">
<div class="col-6">
<div class="text-muted small mb-1">Lifetime Revenue</div>
<div class="fw-bold">@crmStats.TotalRevenue.ToString("C")</div>
</div>
<div class="col-6">
<div class="text-muted small mb-1">Total Collected</div>
<div class="fw-bold text-success">@crmStats.TotalCollected.ToString("C")</div>
</div>
</div>
<hr class="my-2" />
<!-- Footer stats -->
<div class="d-flex justify-content-between text-muted small mt-2">
<span>
@if (crmStats.DaysSinceLastJob.HasValue)
{
<i class="bi bi-calendar-check me-1"></i>
@if (crmStats.DaysSinceLastJob == 0)
{
<span>Last job today</span>
} }
else else
{ {
<span class="text-muted">No contact recorded</span> <span>Last job @crmStats.DaysSinceLastJob days ago</span>
} }
</p> }
else
{
<span>No jobs yet</span>
}
</span>
<span>
<i class="bi bi-file-text me-1"></i>@crmStats.TotalQuotes quote@(crmStats.TotalQuotes == 1 ? "" : "s")
</span>
</div> </div>
<div> <div class="text-muted small mt-1">
<label class="text-muted small mb-1">Customer Since</label> <i class="bi bi-person me-1"></i>Customer since @Model.CreatedAt.ToString("MMM yyyy")
<p class="mb-0">@Model.CreatedAt.ToString("MMMM dd, yyyy")</p>
</div> </div>
</div> </div>
</div> </div>
}
<!-- Preferred Powders -->
@{
var preferredPowders = ViewBag.PreferredPowders as List<PowderCoating.Core.Entities.CustomerPreferredPowder>;
}
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-droplet-fill me-2 text-primary"></i>Preferred Powders
</h5>
</div>
<div class="card-body p-0">
<div id="preferred-powders-list">
@if (preferredPowders != null && preferredPowders.Count > 0)
{
@foreach (var p in preferredPowders)
{
<div class="d-flex align-items-center gap-2 px-3 py-2 border-bottom" data-powder-id="@p.InventoryItemId">
<i class="bi bi-droplet-fill text-primary flex-shrink-0" style="font-size:0.8rem;"></i>
<div class="flex-grow-1">
<span class="small fw-semibold">@p.InventoryItem.Name</span>
@if (!string.IsNullOrEmpty(p.InventoryItem.ColorName))
{
<span class="text-muted small ms-1">&mdash; @p.InventoryItem.ColorName</span>
}
@if (!string.IsNullOrEmpty(p.Notes))
{
<div class="text-muted" style="font-size:0.75rem;">@p.Notes</div>
}
</div>
<button type="button" class="btn btn-sm btn-link text-danger p-0 flex-shrink-0"
onclick="removePreferredPowder(@Model.Id, @p.InventoryItemId)"
title="Remove from preferred">&times;</button>
</div>
}
}
else
{
<div id="no-powders-placeholder" class="px-3 py-2 text-muted small">No preferred powders yet.</div>
}
</div>
<div class="px-3 py-3 border-top bg-light position-relative">
<div class="mb-2">
<input type="text" id="powderSearchInput" class="form-control form-control-sm"
placeholder="Search powder by name or SKU..."
oninput="searchInventoryItems(this.value)"
autocomplete="off" />
<input type="hidden" id="selectedPowderId" />
<div id="powderSearchResults" class="dropdown-menu w-100 show p-0"
style="display:none!important;position:absolute;z-index:1000;"
onfocusout=""></div>
</div>
<div class="mb-2">
<input type="text" id="powderNotes" class="form-control form-control-sm"
placeholder="Optional notes (e.g. &quot;customer prefers this for wheels&quot;)"
maxlength="500" />
</div>
<button type="button" class="btn btn-sm btn-primary w-100"
onclick="addPreferredPowder(@Model.Id)">
<i class="bi bi-plus-circle me-1"></i>Add Powder
</button>
</div>
</div>
</div>
<style>
#powderSearchResults:not(:empty) { display:block!important; max-height:200px; overflow-y:auto; }
</style>
<!-- Quick Actions --> <!-- Quick Actions -->
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
@@ -482,6 +745,17 @@
<a asp-controller="Jobs" asp-action="Create" asp-route-customerId="@Model.Id" class="btn btn-outline-success"> <a asp-controller="Jobs" asp-action="Create" asp-route-customerId="@Model.Id" class="btn btn-outline-success">
<i class="bi bi-plus-circle me-2"></i>New Job <i class="bi bi-plus-circle me-2"></i>New Job
</a> </a>
@{
var crmStatsForActions = ViewBag.CrmStats as PowderCoating.Application.DTOs.Customer.CustomerLifetimeStatsDto;
}
@if (crmStatsForActions?.LastJobId != null)
{
<a asp-controller="Jobs" asp-action="CloneJob" asp-route-id="@crmStatsForActions.LastJobId"
class="btn btn-outline-secondary"
title="Create a new job pre-filled with the last job&apos;s items and settings">
<i class="bi bi-arrow-repeat me-2"></i>Repeat Last Job
</a>
}
<a asp-controller="Quotes" asp-action="Create" asp-route-customerId="@Model.Id" class="btn btn-outline-info"> <a asp-controller="Quotes" asp-action="Create" asp-route-customerId="@Model.Id" class="btn btn-outline-info">
<i class="bi bi-file-text me-2"></i>New Quote <i class="bi bi-file-text me-2"></i>New Quote
</a> </a>
@@ -78,7 +78,7 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label asp-for="Email" class="form-label">Email</label> <label asp-for="Email" class="form-label">Email</label>
<input asp-for="Email" type="email" multiple class="form-control" placeholder="name@example.com (comma-separate multiple)" /> <input asp-for="Email" type="text" class="form-control" placeholder="name@example.com (comma-separate multiple)" />
<span asp-validation-for="Email" class="text-danger"></span> <span asp-validation-for="Email" class="text-danger"></span>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
@@ -95,7 +95,7 @@
<label asp-for="BillingEmail" class="form-label">Billing / Accounting Email <label asp-for="BillingEmail" class="form-label">Billing / Accounting Email
<span class="text-muted fw-normal">(invoices sent here)</span> <span class="text-muted fw-normal">(invoices sent here)</span>
</label> </label>
<input asp-for="BillingEmail" type="email" multiple class="form-control" placeholder="accounting@company.com (comma-separate multiple)" /> <input asp-for="BillingEmail" type="text" class="form-control" placeholder="accounting@company.com (comma-separate multiple)" />
<span asp-validation-for="BillingEmail" class="text-danger"></span> <span asp-validation-for="BillingEmail" class="text-danger"></span>
<div class="form-text">When set, invoices are emailed here instead of the contact email.</div> <div class="form-text">When set, invoices are emailed here instead of the contact email.</div>
</div> </div>
@@ -44,11 +44,21 @@
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<div class="card border-0 shadow-sm"> <a asp-action="Index" asp-route-lowStockOnly="true" class="text-decoration-none"
title="Click to filter list to low stock items">
@{ var _lowStockActive = (bool)(ViewBag.LowStockOnly ?? false); }
<div class="card border-0 shadow-sm @(_lowStockActive ? "border-danger border" : "")"
style="cursor:pointer;transition:box-shadow .15s;">
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div> <div>
<p class="text-muted mb-1" style="font-size: 0.875rem;">Low Stock Items</p> <p class="text-muted mb-1" style="font-size: 0.875rem;">
Low Stock Items
@if (lowStockCount > 0)
{
<i class="bi bi-funnel-fill ms-1 text-danger" style="font-size:.7rem;" title="Click to filter"></i>
}
</p>
<h3 class="mb-0 fw-bold @(lowStockCount > 0 ? "text-danger" : "")">@lowStockCount</h3> <h3 class="mb-0 fw-bold @(lowStockCount > 0 ? "text-danger" : "")">@lowStockCount</h3>
</div> </div>
<div class="rounded-circle p-3" style="background: #fee2e2;"> <div class="rounded-circle p-3" style="background: #fee2e2;">
@@ -57,6 +67,7 @@
</div> </div>
</div> </div>
</div> </div>
</a>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
@@ -102,11 +113,13 @@
<div class="stat-value">@Model.TotalCount</div> <div class="stat-value">@Model.TotalCount</div>
<div class="stat-label">Total</div> <div class="stat-label">Total</div>
</div> </div>
<div class="stat-item"> <a asp-action="Index" asp-route-lowStockOnly="true" class="text-decoration-none">
<div class="stat-item" style="cursor:pointer;">
<div class="stat-icon"><i class="bi bi-exclamation-triangle text-danger"></i></div> <div class="stat-icon"><i class="bi bi-exclamation-triangle text-danger"></i></div>
<div class="stat-value @(lowStockCount > 0 ? "text-danger" : "")">@lowStockCount</div> <div class="stat-value @(lowStockCount > 0 ? "text-danger" : "")">@lowStockCount</div>
<div class="stat-label">Low Stock</div> <div class="stat-label">Low Stock</div>
</div> </div>
</a>
<div class="stat-item"> <div class="stat-item">
<div class="stat-icon"><i class="bi bi-check-circle text-success"></i></div> <div class="stat-icon"><i class="bi bi-check-circle text-success"></i></div>
<div class="stat-value">@activeCount</div> <div class="stat-value">@activeCount</div>
@@ -353,6 +353,11 @@
<label class="form-label fw-semibold">Powder Item</label> <label class="form-label fw-semibold">Powder Item</label>
<p id="euItemName" class="form-control-plaintext text-muted"></p> <p id="euItemName" class="form-control-plaintext text-muted"></p>
</div> </div>
<div class="mb-3">
<label for="euQuantity" class="form-label fw-semibold">Amount Used <small class="text-muted fw-normal" id="euQuantityUom"></small></label>
<input type="number" id="euQuantity" name="quantity" class="form-control" min="0.001" step="any" required />
<div class="form-text">Adjusts the inventory balance by the difference from the original entry.</div>
</div>
<div class="mb-3"> <div class="mb-3">
<label for="euJobId" class="form-label fw-semibold">Job <span class="text-muted fw-normal">(optional)</span></label> <label for="euJobId" class="form-label fw-semibold">Job <span class="text-muted fw-normal">(optional)</span></label>
<select id="euJobId" name="jobId" class="form-select"> <select id="euJobId" name="jobId" class="form-select">
@@ -170,6 +170,12 @@
<input asp-for="CustomerPO" class="form-control" placeholder="Optional" /> <input asp-for="CustomerPO" class="form-control" placeholder="Optional" />
</div> </div>
</div> </div>
<div class="row g-3 mt-1">
<div class="col-md-12">
<label asp-for="ProjectName" class="form-label fw-semibold mb-0">Project Name</label>
<input asp-for="ProjectName" class="form-control" placeholder="Optional &mdash; prints on invoice" />
</div>
</div>
<div class="row g-3 mt-1"> <div class="row g-3 mt-1">
<div class="col-md-12"> <div class="col-md-12">
<div class="d-flex align-items-center gap-1"> <div class="d-flex align-items-center gap-1">
@@ -193,6 +193,13 @@
<p class="mb-0">@Model.CustomerPO</p> <p class="mb-0">@Model.CustomerPO</p>
</div> </div>
} }
@if (!string.IsNullOrWhiteSpace(Model.ProjectName))
{
<div class="col-md-6">
<label class="text-muted small mb-1">Project Name</label>
<p class="mb-0">@Model.ProjectName</p>
</div>
}
@if (!string.IsNullOrWhiteSpace(Model.ExternalReference)) @if (!string.IsNullOrWhiteSpace(Model.ExternalReference))
{ {
<div class="col-md-6"> <div class="col-md-6">
@@ -62,6 +62,12 @@
<input asp-for="CustomerPO" class="form-control" placeholder="Optional" /> <input asp-for="CustomerPO" class="form-control" placeholder="Optional" />
</div> </div>
</div> </div>
<div class="row g-3 mt-1">
<div class="col-md-12">
<label asp-for="ProjectName" class="form-label fw-semibold">Project Name</label>
<input asp-for="ProjectName" class="form-control" placeholder="Optional &mdash; prints on invoice" />
</div>
</div>
<div class="row g-3 mt-1"> <div class="row g-3 mt-1">
<div class="col-md-12"> <div class="col-md-12">
<label asp-for="Terms" class="form-label fw-semibold">Payment Terms</label> <label asp-for="Terms" class="form-label fw-semibold">Payment Terms</label>
@@ -124,6 +124,10 @@
</div> </div>
<input asp-for="CustomerPO" class="form-control" placeholder="Enter PO number" /> <input asp-for="CustomerPO" class="form-control" placeholder="Enter PO number" />
</div> </div>
<div class="col-md-6">
<label asp-for="ProjectName" class="form-label">Project Name</label>
<input asp-for="ProjectName" class="form-control" placeholder="e.g. Kitchen Remodel, Fleet Vehicle #3&hellip;" />
</div>
<div class="col-md-7"> <div class="col-md-7">
<div class="d-flex align-items-center gap-1"> <div class="d-flex align-items-center gap-1">
<label asp-for="SpecialInstructions" class="form-label mb-0">Special Instructions</label> <label asp-for="SpecialInstructions" class="form-label mb-0">Special Instructions</label>
+24 -10
View File
@@ -19,6 +19,10 @@
title="Save this job as a reusable template"> title="Save this job as a reusable template">
<i class="bi bi-layout-text-window-reverse me-2"></i>Save as Template <i class="bi bi-layout-text-window-reverse me-2"></i>Save as Template
</button> </button>
<a asp-action="CloneJob" asp-route-id="@Model.Id" class="btn btn-outline-secondary"
title="Create a new job pre-filled with this job&apos;s items and settings">
<i class="bi bi-arrow-repeat me-2"></i>Clone Job
</a>
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-warning"> <a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-warning">
<i class="bi bi-pencil me-2"></i>Edit <i class="bi bi-pencil me-2"></i>Edit
</a> </a>
@@ -172,6 +176,13 @@
<label class="text-muted small mb-1">Customer PO</label> <label class="text-muted small mb-1">Customer PO</label>
<p class="mb-0">@(Model.CustomerPO ?? "Not provided")</p> <p class="mb-0">@(Model.CustomerPO ?? "Not provided")</p>
</div> </div>
@if (!string.IsNullOrEmpty(Model.ProjectName))
{
<div class="col-md-6">
<label class="text-muted small mb-1">Project</label>
<p class="mb-0">@Model.ProjectName</p>
</div>
}
<div class="col-12"> <div class="col-12">
<label class="text-muted small mb-1">Description</label> <label class="text-muted small mb-1">Description</label>
<p class="mb-0">@Model.Description</p> <p class="mb-0">@Model.Description</p>
@@ -1158,21 +1169,24 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold">Entry Method</label> <label class="form-label fw-semibold">Entry Method</label>
<div class="d-flex gap-3"> <div class="btn-group w-100" role="group">
<div class="form-check"> <button type="button" id="lmBtnUsed" class="btn btn-primary"
<input class="form-check-input" type="radio" name="lmEntryMethod" id="lmMethodUsed" value="used" checked onchange="lmUpdateQuantityLabel()"> onclick="lmSetMethod('used')">
<label class="form-check-label" for="lmMethodUsed">Amount Used</label> <i class="bi bi-droplet me-1"></i>Amount Used
</div> </button>
<div class="form-check"> <button type="button" id="lmBtnRemaining" class="btn btn-outline-primary"
<input class="form-check-input" type="radio" name="lmEntryMethod" id="lmMethodRemaining" value="remaining" onchange="lmUpdateQuantityLabel()"> onclick="lmSetMethod('remaining')">
<label class="form-check-label" for="lmMethodRemaining">Amount Remaining</label> <i class="bi bi-droplet-half me-1"></i>Amount Remaining
</button>
</div> </div>
<div class="form-text">
<span id="lmMethodHint">Enter how much powder you took out of the bag.</span>
</div> </div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label id="lmQtyLabel" class="form-label fw-semibold">Quantity Used <span class="text-danger">*</span></label> <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"> <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 id="lmComputedUsed" class="form-text fw-semibold d-none"></div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold">Reason</label> <label class="form-label fw-semibold">Reason</label>
@@ -3311,7 +3325,7 @@
const inventoryItems = @Html.Raw(ViewBag.InventoryItemsForModal ?? "[]"); const inventoryItems = @Html.Raw(ViewBag.InventoryItemsForModal ?? "[]");
const jobPowderIds = @Html.Raw(ViewBag.JobPowderIds ?? "[]"); const jobPowderIds = @Html.Raw(ViewBag.JobPowderIds ?? "[]");
const jobId = @Model.Id; const jobId = @Model.Id;
const logUrl = '@Url.Action("LogMaterial", "Jobs")'; const logUrl = '@Url.Action("LogMaterial", "Inventory")';
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? ''; const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
window.__logMaterial = { inventoryItems, jobPowderIds, jobId, logUrl, token }; window.__logMaterial = { inventoryItems, jobPowderIds, jobId, logUrl, token };
})(); })();
@@ -101,6 +101,10 @@
<label asp-for="CustomerPO" class="form-label">Customer PO</label> <label asp-for="CustomerPO" class="form-label">Customer PO</label>
<input asp-for="CustomerPO" class="form-control" placeholder="Enter PO number" /> <input asp-for="CustomerPO" class="form-control" placeholder="Enter PO number" />
</div> </div>
<div class="col-md-6">
<label asp-for="ProjectName" class="form-label">Project Name</label>
<input asp-for="ProjectName" class="form-control" placeholder="e.g. Kitchen Remodel, Fleet Vehicle #3&hellip;" />
</div>
<div class="col-md-7"> <div class="col-md-7">
<label asp-for="SpecialInstructions" class="form-label">Special Instructions</label> <label asp-for="SpecialInstructions" class="form-label">Special Instructions</label>
<textarea asp-for="SpecialInstructions" class="form-control" rows="3" placeholder="Any special instructions"></textarea> <textarea asp-for="SpecialInstructions" class="form-control" rows="3" placeholder="Any special instructions"></textarea>
+12 -1
View File
@@ -191,7 +191,14 @@
var isHot = job.DueDate.HasValue && job.DueDate.Value < DateTime.Now var isHot = job.DueDate.HasValue && job.DueDate.Value < DateTime.Now
&& job.StatusCode != "COMPLETED" && job.StatusCode != "READYFORPICKUP" && job.StatusCode != "COMPLETED" && job.StatusCode != "READYFORPICKUP"
&& job.StatusCode != "DELIVERED" && job.StatusCode != "CANCELLED"; && job.StatusCode != "DELIVERED" && job.StatusCode != "CANCELLED";
<tr class="job-row" data-job-id="@job.Id" style="cursor: pointer;"> var tipParts = new List<string>();
if (!string.IsNullOrWhiteSpace(job.Description)) tipParts.Add(job.Description);
if (!string.IsNullOrWhiteSpace(job.CustomerPO)) tipParts.Add("PO: " + job.CustomerPO);
var tipText = string.Join(" · ", tipParts);
<tr class="job-row" data-job-id="@job.Id" style="cursor: pointer;"
@if (!string.IsNullOrEmpty(tipText)) {
<text>data-bs-toggle="tooltip" data-bs-placement="top" data-bs-title="@Html.Encode(tipText)"</text>
}>
<td class="ps-4 @(isHot ? "job-hot-cell" : "")"> <td class="ps-4 @(isHot ? "job-hot-cell" : "")">
<div> <div>
<div class="mono fw-500"> <div class="mono fw-500">
@@ -629,6 +636,10 @@
loadJobStatuses(); loadJobStatuses();
loadJobPriorities(); loadJobPriorities();
// Row tooltips (description + PO)
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el =>
new bootstrap.Tooltip(el, { trigger: 'hover' }));
// / key focuses search input // / key focuses search input
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
if (e.key === '/' && document.activeElement.tagName !== 'INPUT' && document.activeElement.tagName !== 'TEXTAREA') { if (e.key === '/' && document.activeElement.tagName !== 'INPUT' && document.activeElement.tagName !== 'TEXTAREA') {
@@ -357,6 +357,13 @@
<div class="info-value">@Model.CustomerPO</div> <div class="info-value">@Model.CustomerPO</div>
</div> </div>
} }
@if (!string.IsNullOrWhiteSpace(Model.ProjectName))
{
<div class="info-row">
<div class="info-label">Project</div>
<div class="info-value">@Model.ProjectName</div>
</div>
}
</div> </div>
<div class="col-6"> <div class="col-6">
<div class="section-title"> <div class="section-title">
@@ -187,6 +187,12 @@
<input asp-for="CustomerPO" class="form-control" /> <input asp-for="CustomerPO" class="form-control" />
</div> </div>
</div> </div>
<div class="row mt-2">
<div class="col-md-6">
<label asp-for="ProjectName" class="form-label"></label>
<input asp-for="ProjectName" class="form-control" placeholder="e.g. Kitchen Remodel, Fleet Vehicle #3&hellip;" />
</div>
</div>
<div class="row mt-2"> <div class="row mt-2">
<div class="col-md-6"> <div class="col-md-6">
<label asp-for="Notes" class="form-label"></label> <label asp-for="Notes" class="form-label"></label>
@@ -183,6 +183,10 @@
{ {
<p><strong>Customer PO:</strong> @Model.CustomerPO</p> <p><strong>Customer PO:</strong> @Model.CustomerPO</p>
} }
@if (!string.IsNullOrEmpty(Model.ProjectName))
{
<p><strong>Project:</strong> @Model.ProjectName</p>
}
</div> </div>
</div> </div>
@if (!string.IsNullOrEmpty(Model.Description)) @if (!string.IsNullOrEmpty(Model.Description))
@@ -150,6 +150,12 @@
<input asp-for="CustomerPO" class="form-control" /> <input asp-for="CustomerPO" class="form-control" />
</div> </div>
</div> </div>
<div class="row mt-2">
<div class="col-md-6">
<label asp-for="ProjectName" class="form-label"></label>
<input asp-for="ProjectName" class="form-control" placeholder="e.g. Kitchen Remodel, Fleet Vehicle #3&hellip;" />
</div>
</div>
<div class="row mt-2"> <div class="row mt-2">
<div class="col-md-6"> <div class="col-md-6">
<label asp-for="Notes" class="form-label"></label> <label asp-for="Notes" class="form-label"></label>
@@ -1094,30 +1094,31 @@
3: 'Powder Coat' 3: 'Powder Coat'
}; };
// Nozzle multipliers matching ShopCapabilityCalculator // No client-side blast-rate formula here — ShopCapabilityCalculator.cs is the single
const blastNozzleMultipliers = [0, 0, 0.35, 0.55, 0.75, 1.00, 1.30, 1.65, 2.00]; // source of truth. The table uses derivedRate from the server response; the modal
const blastSetupModalTypeMultipliers = { 0: 0.55, 1: 0.70, 2: 1.00, 3: 0.45 }; // live-preview calls /CompanySettings/DeriveBlastRate instead.
const blastSubstrateMultipliers = { 0: 1.00, 1: 0.80, 2: 1.40, 3: 0.90 };
function baseByCfm(cfm) { let _deriveRateTimer = null;
if (cfm <= 0) return 0;
if (cfm <= 5) return 15;
if (cfm <= 10) return 30;
if (cfm <= 15) return 50;
if (cfm <= 25) return 80;
if (cfm <= 40) return 130;
if (cfm <= 60) return 200;
return 300;
}
function deriveBlastRate(cfm, nozzle, setupType, substrate, override) { function updateBlastSetupDerivedRate() {
if (override && parseFloat(override) > 0) return parseFloat(override); clearTimeout(_deriveRateTimer);
const base = baseByCfm(parseFloat(cfm) || 0); _deriveRateTimer = setTimeout(function () {
if (base === 0) return 0; const cfm = document.getElementById('blastSetupCfm').value;
const nm = blastNozzleMultipliers[parseInt(nozzle)] || 1.00; const nozzle = document.getElementById('blastSetupNozzleSize').value;
const sm = blastSetupModalTypeMultipliers[parseInt(setupType)] || 1.00; const setupType = document.getElementById('blastSetupModalType').value;
const bm = blastSubstrateMultipliers[parseInt(substrate)] || 1.00; const substrate = document.getElementById('blastSetupSubstrate').value;
return Math.round(base * nm * sm * bm * 10) / 10; const override = document.getElementById('blastSetupOverride').value;
const el = document.getElementById('blastSetupDerivedRate');
if (!el) return;
const params = new URLSearchParams({ cfm, nozzle, setupType, substrate });
if (override && parseFloat(override) > 0) params.set('rateOverride', override);
fetch('/CompanySettings/DeriveBlastRate?' + params)
.then(r => r.json())
.then(data => { el.textContent = data.rate > 0 ? data.rate + ' sqft/hr' : '—'; })
.catch(() => { el.textContent = '—'; });
}, 250);
} }
window.loadBlastSetups = function () { window.loadBlastSetups = function () {
@@ -1150,7 +1151,7 @@
window.blastSetups.forEach(function (setup) { window.blastSetups.forEach(function (setup) {
const rate = setup.blastRateSqFtPerHourOverride > 0 const rate = setup.blastRateSqFtPerHourOverride > 0
? setup.blastRateSqFtPerHourOverride + ' sqft/hr <span class="badge bg-secondary">Override</span>' ? setup.blastRateSqFtPerHourOverride + ' sqft/hr <span class="badge bg-secondary">Override</span>'
: deriveBlastRate(setup.compressorCfm, setup.blastNozzleSize, setup.setupType, setup.primarySubstrate, 0) + ' sqft/hr'; : (setup.derivedRate > 0 ? setup.derivedRate + ' sqft/hr' : '<span class="text-muted">—</span>');
const defaultBadge = setup.isDefault const defaultBadge = setup.isDefault
? ' <span class="badge bg-primary ms-1">Default</span>' ? ' <span class="badge bg-primary ms-1">Default</span>'
@@ -1179,20 +1180,6 @@
}); });
} }
function updateBlastSetupDerivedRate() {
const cfm = document.getElementById('blastSetupCfm').value;
const nozzle = document.getElementById('blastSetupNozzleSize').value;
const setupType = document.getElementById('blastSetupModalType').value;
const substrate = document.getElementById('blastSetupSubstrate').value;
const override = document.getElementById('blastSetupOverride').value;
const rate = deriveBlastRate(cfm, nozzle, setupType, substrate, override);
const el = document.getElementById('blastSetupDerivedRate');
if (el) {
el.textContent = rate > 0 ? rate + ' sqft/hr' : '—';
}
}
window.showBlastSetupModal = function (setupId = null) { window.showBlastSetupModal = function (setupId = null) {
const modal = new bootstrap.Modal(document.getElementById('blastSetupModal')); const modal = new bootstrap.Modal(document.getElementById('blastSetupModal'));
const form = document.getElementById('blastSetupForm'); const form = document.getElementById('blastSetupForm');
@@ -38,6 +38,148 @@ async function cancelSmsConsent() {
} }
} }
// ── Customer Notes ────────────────────────────────────────────────────────────
async function addCustomerNote(customerId) {
const textarea = document.getElementById('newNoteText');
const importantCb = document.getElementById('newNoteImportant');
const note = textarea?.value?.trim();
if (!note) { toastr.warning('Please enter a note.'); return; }
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
try {
const res = await fetch(`/Customers/AddCustomerNote/${customerId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tok },
body: `note=${encodeURIComponent(note)}&isImportant=${importantCb?.checked ?? false}`
});
const data = await res.json();
if (data.success) {
const list = document.getElementById('customer-notes-list');
const placeholder = document.getElementById('no-notes-placeholder');
if (placeholder) placeholder.remove();
list.insertAdjacentHTML('afterbegin', data.noteHtml);
textarea.value = '';
if (importantCb) importantCb.checked = false;
toastr.success('Note added.');
} else {
toastr.error(data.message || 'Could not add note.');
}
} catch {
toastr.error('An error occurred. Please try again.');
}
}
async function deleteCustomerNote(customerId, noteId) {
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
try {
const res = await fetch(`/Customers/DeleteCustomerNote/${customerId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tok },
body: `noteId=${noteId}`
});
const data = await res.json();
if (data.success) {
document.querySelector(`[data-note-id="${noteId}"]`)?.remove();
const list = document.getElementById('customer-notes-list');
if (list && list.querySelectorAll('.customer-note-item').length === 0)
list.insertAdjacentHTML('afterbegin', '<div id="no-notes-placeholder" class="px-3 py-2 text-muted small">No notes yet.</div>');
} else {
toastr.error(data.message || 'Could not delete note.');
}
} catch {
toastr.error('An error occurred. Please try again.');
}
}
// ── Preferred Powders ─────────────────────────────────────────────────────────
let _powderSearchTimer = null;
function searchInventoryItems(term) {
clearTimeout(_powderSearchTimer);
const dropdown = document.getElementById('powderSearchResults');
if (!term || term.length < 2) { if (dropdown) dropdown.innerHTML = ''; return; }
_powderSearchTimer = setTimeout(async () => {
try {
const res = await fetch(`/Customers/SearchInventoryItems?term=${encodeURIComponent(term)}`);
const data = await res.json();
if (!dropdown) return;
dropdown.innerHTML = data.length === 0
? '<div class="dropdown-item text-muted small">No results</div>'
: data.map(i => `<button type="button" class="dropdown-item small"
onclick="selectPowder(${i.id}, ${JSON.stringify(i.name + (i.colorName ? ' — ' + i.colorName : ''))})">${i.name}${i.colorName ? ' <span class=\'text-muted\'>' + i.colorName + '</span>' : ''} <span class="badge bg-light text-muted border">${i.sku}</span></button>`).join('');
} catch { /* silent */ }
}, 300);
}
function selectPowder(itemId, label) {
document.getElementById('selectedPowderId').value = itemId;
document.getElementById('powderSearchInput').value = label;
const dropdown = document.getElementById('powderSearchResults');
if (dropdown) dropdown.innerHTML = '';
}
async function addPreferredPowder(customerId) {
const itemId = document.getElementById('selectedPowderId')?.value;
const notes = document.getElementById('powderNotes')?.value?.trim() ?? '';
if (!itemId) { toastr.warning('Please search for and select a powder first.'); return; }
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
try {
const res = await fetch(`/Customers/AddPreferredPowder/${customerId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tok },
body: `inventoryItemId=${itemId}&notes=${encodeURIComponent(notes)}`
});
const data = await res.json();
if (data.success) {
const list = document.getElementById('preferred-powders-list');
const placeholder = document.getElementById('no-powders-placeholder');
if (placeholder) placeholder.remove();
const notesHtml = data.notes ? `<div class="text-muted" style="font-size:0.75rem;">${data.notes}</div>` : '';
list.insertAdjacentHTML('beforeend',
`<div class="d-flex align-items-center gap-2 px-3 py-2 border-bottom" data-powder-id="${data.itemId}">
<i class="bi bi-droplet-fill text-primary flex-shrink-0" style="font-size:0.8rem;"></i>
<div class="flex-grow-1"><span class="small fw-semibold">${data.itemName}</span>${notesHtml}</div>
<button type="button" class="btn btn-sm btn-link text-danger p-0"
onclick="removePreferredPowder(${customerId}, ${data.itemId})" title="Remove">&times;</button>
</div>`);
document.getElementById('powderSearchInput').value = '';
document.getElementById('selectedPowderId').value = '';
if (document.getElementById('powderNotes')) document.getElementById('powderNotes').value = '';
toastr.success(`${data.itemName} added to preferred powders.`);
} else {
toastr.warning(data.message || 'Could not add powder.');
}
} catch {
toastr.error('An error occurred. Please try again.');
}
}
async function removePreferredPowder(customerId, itemId) {
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
try {
const res = await fetch(`/Customers/RemovePreferredPowder/${customerId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tok },
body: `itemId=${itemId}`
});
const data = await res.json();
if (data.success) {
document.querySelector(`[data-powder-id="${itemId}"]`)?.remove();
const list = document.getElementById('preferred-powders-list');
if (list && list.querySelectorAll('[data-powder-id]').length === 0)
list.insertAdjacentHTML('afterbegin', '<div id="no-powders-placeholder" class="px-3 py-2 text-muted small">No preferred powders yet.</div>');
} else {
toastr.error(data.message || 'Could not remove powder.');
}
} catch {
toastr.error('An error occurred. Please try again.');
}
}
window.updateCustomerSmsStatus = function () { window.updateCustomerSmsStatus = function () {
const section = document.getElementById('sms-status-section'); const section = document.getElementById('sms-status-section');
if (!section) return; if (!section) return;
@@ -18,6 +18,7 @@ async function openUsageEdit(transactionId) {
document.getElementById('euTxnId').value = data.transactionId; document.getElementById('euTxnId').value = data.transactionId;
document.getElementById('euItemName').textContent = data.itemName || '—'; document.getElementById('euItemName').textContent = data.itemName || '—';
document.getElementById('euQuantity').value = data.quantity != null ? parseFloat(data.quantity).toFixed(4) : '';
document.getElementById('euDate').value = data.transactionDate; document.getElementById('euDate').value = data.transactionDate;
document.getElementById('euNotes').value = data.notes || ''; document.getElementById('euNotes').value = data.notes || '';
@@ -54,6 +55,7 @@ document.getElementById('euSaveBtn').addEventListener('click', async () => {
const token = form.querySelector('input[name="__RequestVerificationToken"]')?.value; const token = form.querySelector('input[name="__RequestVerificationToken"]')?.value;
const params = new URLSearchParams({ const params = new URLSearchParams({
id: document.getElementById('euTxnId').value, id: document.getElementById('euTxnId').value,
quantity: document.getElementById('euQuantity').value,
jobId: document.getElementById('euJobId').value, jobId: document.getElementById('euJobId').value,
notes: document.getElementById('euNotes').value, notes: document.getElementById('euNotes').value,
transactionDate: document.getElementById('euDate').value, transactionDate: document.getElementById('euDate').value,
+17 -11
View File
@@ -691,7 +691,7 @@ function renderSalesFields() {
</button> </button>
</div> </div>
<div id="wzMerchDropdown" <div id="wzMerchDropdown"
style="display:none;max-height:220px;overflow-y:auto;z-index:1060;background:#fff;border:1px solid #dee2e6;border-radius:0.375rem;box-shadow:0 4px 12px rgba(0,0,0,.12);position:fixed;"> style="display:none;max-height:220px;overflow-y:auto;z-index:1060;background:var(--bs-body-bg);color:var(--bs-body-color);border:1px solid var(--bs-border-color);border-radius:0.375rem;box-shadow:0 4px 12px rgba(0,0,0,.12);position:fixed;">
</div> </div>
</div> </div>
<div class="text-danger d-none mt-1" id="err_salesCatalogItemId">Please select an item.</div> <div class="text-danger d-none mt-1" id="err_salesCatalogItemId">Please select an item.</div>
@@ -773,7 +773,7 @@ function wzMerchComboRender(query) {
`<div class="wz-merch-opt" style="padding:.35rem .75rem .35rem 1.25rem;font-size:.875rem;cursor:pointer;" `<div class="wz-merch-opt" style="padding:.35rem .75rem .35rem 1.25rem;font-size:.875rem;cursor:pointer;"
data-id="${m.id}" data-name="${escHtml(m.name)}" data-price="${m.price}" data-sku="${escHtml(m.sku || '')}" data-id="${m.id}" data-name="${escHtml(m.name)}" data-price="${m.price}" data-sku="${escHtml(m.sku || '')}"
onmousedown="event.preventDefault();wzMerchComboSelect(this)" onmousedown="event.preventDefault();wzMerchComboSelect(this)"
onmouseenter="this.style.background='#f0f4ff'" onmouseenter="this.style.background='var(--bs-secondary-bg)'"
onmouseleave="this.style.background=''"> onmouseleave="this.style.background=''">
${escHtml(m.name)}${m.sku ? ` <span class="text-muted">[${escHtml(m.sku)}]</span>` : ''} <span class="text-muted"> $${parseFloat(m.price).toFixed(2)}</span> ${escHtml(m.name)}${m.sku ? ` <span class="text-muted">[${escHtml(m.sku)}]</span>` : ''} <span class="text-muted"> $${parseFloat(m.price).toFixed(2)}</span>
</div>` </div>`
@@ -1511,7 +1511,10 @@ async function aiAnalyze() {
document.getElementById('ai_errorAlert')?.classList.add('d-none'); document.getElementById('ai_errorAlert')?.classList.add('d-none');
const blastSetupIdEl = document.getElementById('ai_blastSetupId'); const blastSetupIdEl = document.getElementById('ai_blastSetupId');
const blastSetupId = blastSetupIdEl ? (parseInt(blastSetupIdEl.value) || null) : null; const _defaultSetup = blastSetupData.find(s => s.isDefault) || blastSetupData[0];
const blastSetupId = blastSetupIdEl
? (parseInt(blastSetupIdEl.value) || null)
: (_defaultSetup ? _defaultSetup.id : null);
const payload = { const payload = {
photoTempIds: wz.ai.tempIds, photoTempIds: wz.ai.tempIds,
@@ -1591,7 +1594,10 @@ async function aiSendFollowup() {
wz.data.quantity = qty; // persist before renderStep re-renders wz.data.quantity = qty; // persist before renderStep re-renders
const blastSetupIdEl2 = document.getElementById('ai_blastSetupId'); const blastSetupIdEl2 = document.getElementById('ai_blastSetupId');
const blastSetupId2 = blastSetupIdEl2 ? (parseInt(blastSetupIdEl2.value) || null) : null; const _defaultSetup2 = blastSetupData.find(s => s.isDefault) || blastSetupData[0];
const blastSetupId2 = blastSetupIdEl2
? (parseInt(blastSetupIdEl2.value) || null)
: (_defaultSetup2 ? _defaultSetup2.id : null);
const payload = { const payload = {
photoTempIds: wz.ai.tempIds, photoTempIds: wz.ai.tempIds,
@@ -1909,7 +1915,7 @@ function buildCoatRowHtml(i, coat) {
<input type="hidden" id="coat_inventoryItemId_${i}"> <input type="hidden" id="coat_inventoryItemId_${i}">
<div id="coat_powder_dropdown_${i}" <div id="coat_powder_dropdown_${i}"
class="powder-combo-dropdown" class="powder-combo-dropdown"
style="display:none;max-height:220px;overflow-y:auto;z-index:1060;border-radius:0.375rem;box-shadow:0 4px 12px rgba(0,0,0,.12);"> style="display:none;max-height:220px;overflow-y:auto;z-index:1060;background:var(--bs-body-bg);color:var(--bs-body-color);border:1px solid var(--bs-border-color);border-radius:0.375rem;box-shadow:0 4px 12px rgba(0,0,0,.12);">
</div> </div>
</div> </div>
</div> </div>
@@ -1968,7 +1974,7 @@ function buildCoatRowHtml(i, coat) {
</div> </div>
<div id="coat_catalog_results_${i}" <div id="coat_catalog_results_${i}"
class="powder-combo-dropdown" class="powder-combo-dropdown"
style="display:none;max-height:220px;overflow-y:auto;z-index:1060;border-radius:0.375rem;box-shadow:0 4px 12px rgba(0,0,0,.12);"> style="display:none;max-height:220px;overflow-y:auto;z-index:1060;background:var(--bs-body-bg);color:var(--bs-body-color);border:1px solid var(--bs-border-color);border-radius:0.375rem;box-shadow:0 4px 12px rgba(0,0,0,.12);">
</div> </div>
</div> </div>
<div class="col-sm-6"> <div class="col-sm-6">
@@ -2166,7 +2172,7 @@ function powderComboRender(i, query) {
data-val="${escHtml(String(p.value))}" data-val="${escHtml(String(p.value))}"
data-txt="${escHtml(p.text)}" data-txt="${escHtml(p.text)}"
onmousedown="event.preventDefault(); powderComboSelect(${i}, this.dataset.val, this.dataset.txt)" onmousedown="event.preventDefault(); powderComboSelect(${i}, this.dataset.val, this.dataset.txt)"
onmouseenter="this.style.background=document.documentElement.getAttribute('data-bs-theme')==='dark'?'#2c3a5a':'#f0f4ff'" onmouseenter="this.style.background='var(--bs-secondary-bg)'"
onmouseleave="this.classList.contains('pw-active')?null:this.style.background=''"> onmouseleave="this.classList.contains('pw-active')?null:this.style.background=''">
${escHtml(displayText)}${badge} ${escHtml(displayText)}${badge}
</div>`; </div>`;
@@ -2214,12 +2220,12 @@ function powderComboKey(event, i) {
event.preventDefault(); event.preventDefault();
idx = Math.min(idx + 1, items.length - 1); idx = Math.min(idx + 1, items.length - 1);
items.forEach(it => { it.classList.remove('pw-active'); it.style.background = ''; }); items.forEach(it => { it.classList.remove('pw-active'); it.style.background = ''; });
if (items[idx]) { items[idx].classList.add('pw-active'); items[idx].style.background = '#e8eeff'; items[idx].scrollIntoView({ block: 'nearest' }); } if (items[idx]) { items[idx].classList.add('pw-active'); items[idx].style.background = 'var(--bs-primary-bg-subtle)'; items[idx].scrollIntoView({ block: 'nearest' }); }
} else if (event.key === 'ArrowUp') { } else if (event.key === 'ArrowUp') {
event.preventDefault(); event.preventDefault();
idx = Math.max(idx - 1, 0); idx = Math.max(idx - 1, 0);
items.forEach(it => { it.classList.remove('pw-active'); it.style.background = ''; }); items.forEach(it => { it.classList.remove('pw-active'); it.style.background = ''; });
if (items[idx]) { items[idx].classList.add('pw-active'); items[idx].style.background = '#e8eeff'; items[idx].scrollIntoView({ block: 'nearest' }); } if (items[idx]) { items[idx].classList.add('pw-active'); items[idx].style.background = 'var(--bs-primary-bg-subtle)'; items[idx].scrollIntoView({ block: 'nearest' }); }
} else if (event.key === 'Enter') { } else if (event.key === 'Enter') {
event.preventDefault(); event.preventDefault();
const active = dd.querySelector('.pw-active') || items[0]; const active = dd.querySelector('.pw-active') || items[0];
@@ -2272,7 +2278,7 @@ function customPowderCatalogSearch(i, query) {
const price = r.unitPrice ? `<span class="text-muted small ms-1">$${parseFloat(r.unitPrice).toFixed(2)}/lb</span>` : ''; const price = r.unitPrice ? `<span class="text-muted small ms-1">$${parseFloat(r.unitPrice).toFixed(2)}/lb</span>` : '';
return `<div class="powder-opt" style="padding:.4rem .75rem;font-size:.83rem;white-space:normal;line-height:1.3;cursor:pointer;" return `<div class="powder-opt" style="padding:.4rem .75rem;font-size:.83rem;white-space:normal;line-height:1.3;cursor:pointer;"
onmousedown="event.preventDefault(); applyCustomCatalogResult(${i}, ${JSON.stringify(r).replace(/"/g, '&quot;')})" onmousedown="event.preventDefault(); applyCustomCatalogResult(${i}, ${JSON.stringify(r).replace(/"/g, '&quot;')})"
onmouseenter="this.style.background='#f0f4ff'" onmouseenter="this.style.background='var(--bs-secondary-bg)'"
onmouseleave="this.style.background=''"> onmouseleave="this.style.background=''">
<strong>${escHtml(r.colorName)}</strong> ${escHtml(r.vendorName)} <strong>${escHtml(r.colorName)}</strong> ${escHtml(r.vendorName)}
<span class="text-muted small ms-1">${escHtml(r.sku || '')}</span> <span class="text-muted small ms-1">${escHtml(r.sku || '')}</span>
@@ -2363,7 +2369,7 @@ function powderCatalogSearch(i, query) {
: ''; : '';
return `<div class="powder-opt" style="padding:.35rem .75rem;font-size:.83rem;white-space:normal;line-height:1.3;cursor:pointer;" return `<div class="powder-opt" style="padding:.35rem .75rem;font-size:.83rem;white-space:normal;line-height:1.3;cursor:pointer;"
onmousedown="event.preventDefault(); createIncomingFromCatalog(${i}, ${r.id})" onmousedown="event.preventDefault(); createIncomingFromCatalog(${i}, ${r.id})"
onmouseenter="this.style.background='#fff8e1'" onmouseenter="this.style.background='var(--bs-warning-bg-subtle)'"
onmouseleave="this.style.background=''"> onmouseleave="this.style.background=''">
<i class="bi bi-truck text-warning me-1"></i> <i class="bi bi-truck text-warning me-1"></i>
<strong>${escHtml(r.colorName)}</strong> ${escHtml(r.vendorName)} ${escHtml(r.sku || '')} <strong>${escHtml(r.colorName)}</strong> ${escHtml(r.vendorName)} ${escHtml(r.sku || '')}
@@ -6,9 +6,64 @@
let _items = []; let _items = [];
let _jobPowderIds = new Set(); let _jobPowderIds = new Set();
let _modal = null; let _modal = null;
let _selectedItemId = 0;
let _entryMethod = 'used'; // 'used' | 'remaining'
// ── Mode toggle ───────────────────────────────────────────────────────────
window.lmSetMethod = function (method) {
_entryMethod = method;
const btnUsed = document.getElementById('lmBtnUsed');
const btnRemaining = document.getElementById('lmBtnRemaining');
const hintEl = document.getElementById('lmMethodHint');
const qtyLabel = document.getElementById('lmQtyLabel');
if (method === 'remaining') {
btnUsed.className = 'btn btn-outline-primary';
btnRemaining.className = 'btn btn-primary';
hintEl.textContent = 'Enter how much is LEFT in the bag — the system calculates what was used.';
qtyLabel.innerHTML = 'Weight Remaining in Bag <span class="text-danger">*</span>';
} else {
btnUsed.className = 'btn btn-primary';
btnRemaining.className = 'btn btn-outline-primary';
hintEl.textContent = 'Enter how much powder you took out of the bag.';
qtyLabel.innerHTML = 'Quantity Used <span class="text-danger">*</span>';
}
lmUpdatePreview();
};
// ── Live preview (always visible once qty + item are set) ─────────────────
function lmUpdatePreview() {
const computedDiv = document.getElementById('lmComputedUsed');
if (!_selectedItemId || !computedDiv) { computedDiv?.classList.add('d-none'); return; }
const item = _items.find(it => it.id === _selectedItemId);
const onHand = item ? (parseFloat(item.quantityOnHand) || 0) : 0;
const qty = parseFloat(document.getElementById('lmQuantity').value) || 0;
if (qty <= 0) { computedDiv.classList.add('d-none'); return; }
const uom = item?.unitOfMeasure || '';
if (_entryMethod === 'remaining') {
const used = onHand - qty;
if (used <= 0) {
computedDiv.className = 'form-text fw-semibold text-danger';
computedDiv.textContent = 'Remaining cannot be ≥ current stock (' + onHand.toFixed(2) + ' ' + uom + ').';
} else {
computedDiv.className = 'form-text fw-semibold text-success';
computedDiv.textContent =
'Will log ' + used.toFixed(2) + ' ' + uom + ' used — new balance: ' + qty.toFixed(2) + ' ' + uom;
}
} else {
const newBal = onHand - qty;
const col = newBal < 0 ? 'text-danger' : 'text-success';
computedDiv.className = 'form-text fw-semibold ' + col;
computedDiv.textContent =
'Will log ' + qty.toFixed(2) + ' ' + uom + ' used — new balance: ' + newBal.toFixed(2) + ' ' + uom;
}
computedDiv.classList.remove('d-none');
}
// ── Combobox state ──────────────────────────────────────────────────────── // ── Combobox state ────────────────────────────────────────────────────────
let _selectedItemId = 0;
function lmComboInput() { function lmComboInput() {
const q = document.getElementById('lmItemSearch')?.value?.toLowerCase() || ''; const q = document.getElementById('lmItemSearch')?.value?.toLowerCase() || '';
@@ -16,7 +71,7 @@
lmComboShow(); lmComboShow();
_selectedItemId = 0; _selectedItemId = 0;
document.getElementById('lmItemBalance').classList.add('d-none'); document.getElementById('lmItemBalance').classList.add('d-none');
lmOnQtyInput(); lmUpdatePreview();
} }
function lmComboOpen() { function lmComboOpen() {
@@ -111,7 +166,7 @@
const balDiv = document.getElementById('lmItemBalance'); const balDiv = document.getElementById('lmItemBalance');
balDiv.textContent = 'Current stock: ' + qty.toFixed(2) + (uom ? ' ' + uom : ''); balDiv.textContent = 'Current stock: ' + qty.toFixed(2) + (uom ? ' ' + uom : '');
balDiv.classList.remove('d-none'); balDiv.classList.remove('d-none');
lmOnQtyInput(); lmUpdatePreview();
}; };
window.lmComboInput = lmComboInput; window.lmComboInput = lmComboInput;
@@ -152,39 +207,14 @@
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
} }
// ── Quantity / label logic ─────────────────────────────────────────────── // ── Kept for backward-compat with any inline onchange handlers that may exist
window.lmUpdateQuantityLabel = function () { lmUpdatePreview(); };
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 ───────────────────────────────────────────────────── // ── Modal open / save ─────────────────────────────────────────────────────
window.openLogMaterialModal = function () { window.openLogMaterialModal = function () {
_selectedItemId = 0; _selectedItemId = 0;
_entryMethod = 'used';
document.getElementById('lmItemSearch').value = ''; document.getElementById('lmItemSearch').value = '';
document.getElementById('lmItemBalance').classList.add('d-none'); document.getElementById('lmItemBalance').classList.add('d-none');
document.getElementById('lmQuantity').value = ''; document.getElementById('lmQuantity').value = '';
@@ -193,8 +223,7 @@
document.getElementById('lmNotes').value = ''; document.getElementById('lmNotes').value = '';
document.getElementById('lmAlert').classList.add('d-none'); document.getElementById('lmAlert').classList.add('d-none');
document.getElementById('lmSaveBtn').disabled = false; document.getElementById('lmSaveBtn').disabled = false;
document.getElementById('lmMethodUsed').checked = true; lmSetMethod('used');
window.lmUpdateQuantityLabel();
lmComboClose(); lmComboClose();
if (_modal) _modal.show(); if (_modal) _modal.show();
}; };
@@ -214,14 +243,14 @@
const qtyInput = parseFloat(document.getElementById('lmQuantity').value) || 0; const qtyInput = parseFloat(document.getElementById('lmQuantity').value) || 0;
if (qtyInput <= 0) { showError('Please enter a quantity greater than zero.'); return; } if (qtyInput <= 0) { showError('Please enter a quantity greater than zero.'); return; }
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
let quantityUsed = qtyInput;
if (method === 'remaining') {
const item = _items.find(it => it.id === _selectedItemId); const item = _items.find(it => it.id === _selectedItemId);
const onHand = item ? (parseFloat(item.quantityOnHand) || 0) : 0; const onHand = item ? (parseFloat(item.quantityOnHand) || 0) : 0;
let quantityUsed = qtyInput;
if (_entryMethod === 'remaining') {
quantityUsed = onHand - qtyInput; quantityUsed = onHand - qtyInput;
if (quantityUsed <= 0) { if (quantityUsed <= 0) {
showError('Remaining quantity cannot be equal to or greater than the current stock (' + onHand.toFixed(2) + ').'); showError('Remaining cannot be equal to or greater than the current stock (' + onHand.toFixed(2) + ').');
return; return;
} }
} }
@@ -269,9 +298,8 @@
_jobPowderIds = new Set(cfg.jobPowderIds || []); _jobPowderIds = new Set(cfg.jobPowderIds || []);
_modal = new bootstrap.Modal(document.getElementById('logMaterialModal')); _modal = new bootstrap.Modal(document.getElementById('logMaterialModal'));
document.getElementById('lmQuantity').addEventListener('input', lmOnQtyInput); document.getElementById('lmQuantity').addEventListener('input', lmUpdatePreview);
// Close dropdown when clicking outside
document.addEventListener('click', function (e) { document.addEventListener('click', function (e) {
if (!e.target.closest('#lmItemSearch') && if (!e.target.closest('#lmItemSearch') &&
!e.target.closest('#lmItemDropdown') && !e.target.closest('#lmItemDropdown') &&
@@ -0,0 +1,378 @@
using AutoMapper;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using PowderCoating.Application.DTOs.Customer;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Infrastructure.Repositories;
using PowderCoating.Web.Controllers;
using System.Security.Claims;
namespace PowderCoating.UnitTests;
public class CustomersControllerCrmTests
{
// ── Details — guard ───────────────────────────────────────────────────
[Fact]
public async Task Details_WhenCustomerNotFound_ReturnsNotFound()
{
await using var context = CreateContext();
var controller = CreateController(context, companyId: 1);
var result = await controller.Details(id: 999);
Assert.IsType<NotFoundResult>(result);
}
// ── Details — zero-history customer ───────────────────────────────────
[Fact]
public async Task Details_WithNoHistory_ProducesZeroStats()
{
await using var context = CreateContext();
SeedCustomer(context, id: 1, companyId: 1);
await context.SaveChangesAsync();
var controller = CreateController(context, companyId: 1);
var result = await controller.Details(id: 1);
var view = Assert.IsType<ViewResult>(result);
var stats = view.ViewData["CrmStats"] as CustomerLifetimeStatsDto;
Assert.NotNull(stats);
Assert.Equal(0, stats.TotalJobs);
Assert.Equal(0, stats.TotalQuotes);
Assert.Equal(0m, stats.TotalRevenue);
Assert.Equal(0m, stats.AverageJobValue); // no divide-by-zero
Assert.Null(stats.DaysSinceLastJob);
}
[Fact]
public async Task Details_WithNoHistory_DoesNotRenderTimeline()
{
await using var context = CreateContext();
SeedCustomer(context, id: 1, companyId: 1);
await context.SaveChangesAsync();
var controller = CreateController(context, companyId: 1);
var result = await controller.Details(id: 1);
var view = Assert.IsType<ViewResult>(result);
var timeline = view.ViewData["Timeline"] as List<CustomerTimelineEventDto>;
Assert.NotNull(timeline);
Assert.Empty(timeline);
}
// ── Details — stats calculation ───────────────────────────────────────
[Fact]
public async Task Details_WithJobsAndInvoices_CalculatesStatsCorrectly()
{
await using var context = CreateContext();
SeedCustomer(context, id: 1, companyId: 1);
var activeStatus = SeedJobStatus(context, id: 1, isTerminal: false);
SeedJob(context, id: 1, customerId: 1, companyId: 1, statusId: activeStatus.Id, finalPrice: 300m);
SeedJob(context, id: 2, customerId: 1, companyId: 1, statusId: activeStatus.Id, finalPrice: 700m);
SeedInvoice(context, id: 1, customerId: 1, companyId: 1, total: 300m, amountPaid: 300m, status: InvoiceStatus.Paid);
SeedInvoice(context, id: 2, customerId: 1, companyId: 1, total: 700m, amountPaid: 400m, status: InvoiceStatus.PartiallyPaid);
await context.SaveChangesAsync();
var controller = CreateController(context, companyId: 1);
var result = await controller.Details(id: 1);
var view = Assert.IsType<ViewResult>(result);
var stats = view.ViewData["CrmStats"] as CustomerLifetimeStatsDto;
Assert.NotNull(stats);
Assert.Equal(2, stats.TotalJobs);
Assert.Equal(2, stats.ActiveJobs);
Assert.Equal(2, stats.TotalInvoices);
Assert.Equal(1000m, stats.TotalRevenue);
Assert.Equal(700m, stats.TotalCollected);
Assert.Equal(500m, stats.AverageJobValue);
}
// ── Details — voided invoices excluded ────────────────────────────────
[Fact]
public async Task Details_VoidedInvoicesExcludedFromRevenueAndCollected()
{
await using var context = CreateContext();
SeedCustomer(context, id: 1, companyId: 1);
var status = SeedJobStatus(context, id: 1, isTerminal: false);
SeedJob(context, id: 1, customerId: 1, companyId: 1, statusId: status.Id, finalPrice: 500m);
SeedInvoice(context, id: 1, customerId: 1, companyId: 1, total: 500m, amountPaid: 500m, status: InvoiceStatus.Paid);
SeedInvoice(context, id: 2, customerId: 1, companyId: 1, total: 999m, amountPaid: 0m, status: InvoiceStatus.Voided);
await context.SaveChangesAsync();
var controller = CreateController(context, companyId: 1);
var result = await controller.Details(id: 1);
var view = Assert.IsType<ViewResult>(result);
var stats = view.ViewData["CrmStats"] as CustomerLifetimeStatsDto;
Assert.NotNull(stats);
Assert.Equal(500m, stats.TotalRevenue); // voided invoice excluded
Assert.Equal(500m, stats.TotalCollected); // voided invoice excluded
Assert.Equal(2, stats.TotalInvoices); // count includes voided (informational)
}
// ── Details — active-job count excludes terminal statuses ─────────────
[Fact]
public async Task Details_ActiveJobsExcludesTerminalStatuses()
{
await using var context = CreateContext();
SeedCustomer(context, id: 1, companyId: 1);
var active = SeedJobStatus(context, id: 1, isTerminal: false);
var completed = SeedJobStatus(context, id: 2, isTerminal: true);
SeedJob(context, id: 1, customerId: 1, companyId: 1, statusId: active.Id, finalPrice: 100m);
SeedJob(context, id: 2, customerId: 1, companyId: 1, statusId: completed.Id, finalPrice: 200m);
SeedJob(context, id: 3, customerId: 1, companyId: 1, statusId: completed.Id, finalPrice: 300m);
await context.SaveChangesAsync();
var controller = CreateController(context, companyId: 1);
var result = await controller.Details(id: 1);
var view = Assert.IsType<ViewResult>(result);
var stats = view.ViewData["CrmStats"] as CustomerLifetimeStatsDto;
Assert.NotNull(stats);
Assert.Equal(3, stats.TotalJobs);
Assert.Equal(1, stats.ActiveJobs);
}
// ── Details — timeline cap and sort ───────────────────────────────────
[Fact]
public async Task Details_TimelineCappedAt15Events()
{
await using var context = CreateContext();
SeedCustomer(context, id: 1, companyId: 1);
var status = SeedJobStatus(context, id: 1, isTerminal: false);
for (int i = 1; i <= 18; i++)
{
context.Jobs.Add(new Job
{
Id = i,
CompanyId = 1,
CustomerId = 1,
JobStatusId = status.Id,
JobNumber = $"JOB-0001-{i:D4}",
Description = $"Job {i}",
FinalPrice = 100m,
CreatedAt = new DateTime(2026, 1, i, 0, 0, 0, DateTimeKind.Utc)
});
}
await context.SaveChangesAsync();
var controller = CreateController(context, companyId: 1);
var result = await controller.Details(id: 1);
var view = Assert.IsType<ViewResult>(result);
var timeline = view.ViewData["Timeline"] as List<CustomerTimelineEventDto>;
Assert.NotNull(timeline);
Assert.Equal(15, timeline.Count);
}
[Fact]
public async Task Details_TimelineIsSortedNewestFirst()
{
await using var context = CreateContext();
SeedCustomer(context, id: 1, companyId: 1);
// Use Invoice.InvoiceDate for timeline dates — SaveChangesAsync stamps CreatedAt but
// does not touch InvoiceDate, so we can seed distinct values that survive the save.
SeedInvoice(context, id: 1, customerId: 1, companyId: 1, total: 100m, amountPaid: 100m,
status: InvoiceStatus.Paid, invoiceDate: new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc));
SeedInvoice(context, id: 2, customerId: 1, companyId: 1, total: 200m, amountPaid: 0m,
status: InvoiceStatus.Sent, invoiceDate: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
SeedInvoice(context, id: 3, customerId: 1, companyId: 1, total: 300m, amountPaid: 0m,
status: InvoiceStatus.Sent, invoiceDate: new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc));
await context.SaveChangesAsync();
var controller = CreateController(context, companyId: 1);
var result = await controller.Details(id: 1);
var view = Assert.IsType<ViewResult>(result);
var timeline = view.ViewData["Timeline"] as List<CustomerTimelineEventDto>;
Assert.NotNull(timeline);
Assert.Equal(3, timeline.Count);
Assert.True(timeline[0].Date > timeline[1].Date, "First event should be the newest");
Assert.True(timeline[1].Date > timeline[2].Date, "Events should be descending");
}
// ── Details — tenant isolation ────────────────────────────────────────
[Fact]
public async Task Details_DoesNotIncludeJobsFromOtherCompanies()
{
await using var context = CreateContext();
SeedCustomer(context, id: 1, companyId: 1);
var status = SeedJobStatus(context, id: 1, isTerminal: false);
SeedJob(context, id: 1, customerId: 1, companyId: 1, statusId: status.Id, finalPrice: 500m); // this company
SeedJob(context, id: 2, customerId: 1, companyId: 2, statusId: status.Id, finalPrice: 999m); // other company
await context.SaveChangesAsync();
var controller = CreateController(context, companyId: 1);
var result = await controller.Details(id: 1);
var view = Assert.IsType<ViewResult>(result);
var stats = view.ViewData["CrmStats"] as CustomerLifetimeStatsDto;
Assert.NotNull(stats);
Assert.Equal(1, stats.TotalJobs);
Assert.Equal(500m, stats.AverageJobValue);
}
// ── Helpers ───────────────────────────────────────────────────────────
private static CustomersController CreateController(ApplicationDbContext context, int companyId)
{
var uow = new UnitOfWork(context);
// Tests cover CrmStats/Timeline logic, not DTO mapping — stub mapper to return a valid model
var mapperMock = new Mock<IMapper>();
mapperMock
.Setup(m => m.Map<CustomerDto>(It.IsAny<Customer>()))
.Returns((Customer c) => new CustomerDto { Id = c.Id, CompanyName = c.CompanyName, IsActive = c.IsActive });
var mapper = mapperMock.Object;
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(t => t.GetCurrentCompanyId()).Returns(companyId);
var controller = new CustomersController(
uow,
mapper,
Mock.Of<ILogger<CustomersController>>(),
Mock.Of<INotificationService>(),
Mock.Of<ISubscriptionService>(),
tenantContext.Object,
CreateUserManagerMock().Object,
Mock.Of<IFinancialReportService>());
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
};
return controller;
}
private static void SeedCustomer(ApplicationDbContext context, int id, int companyId)
{
context.Customers.Add(new Customer
{
Id = id,
CompanyId = companyId,
CompanyName = $"Test Customer {id}",
IsActive = true,
CreatedAt = DateTime.UtcNow
});
}
private static JobStatusLookup SeedJobStatus(ApplicationDbContext context, int id, bool isTerminal)
{
var status = new JobStatusLookup
{
Id = id,
StatusCode = isTerminal ? "COMPLETED" : "IN_PROGRESS",
DisplayName = isTerminal ? "Completed" : "In Progress",
DisplayOrder = id,
IsTerminalStatus = isTerminal
};
context.JobStatusLookups.Add(status);
return status;
}
private static void SeedJob(
ApplicationDbContext context, int id, int customerId, int companyId, int statusId, decimal finalPrice)
{
context.Jobs.Add(new Job
{
Id = id,
CompanyId = companyId,
CustomerId = customerId,
JobStatusId = statusId,
JobNumber = $"JOB-0001-{id:D4}",
Description = $"Job {id}",
FinalPrice = finalPrice,
CreatedAt = DateTime.UtcNow
});
}
private static Job MakeJob(int id, int customerId, int companyId, int statusId, DateTime date) =>
new Job
{
Id = id,
CompanyId = companyId,
CustomerId = customerId,
JobStatusId = statusId,
JobNumber = $"JOB-0001-{id:D4}",
Description = $"Job {id}",
FinalPrice = 100m,
CreatedAt = date
};
private static void SeedInvoice(
ApplicationDbContext context, int id, int customerId, int companyId,
decimal total, decimal amountPaid, InvoiceStatus status,
DateTime? invoiceDate = null)
{
context.Invoices.Add(new Invoice
{
Id = id,
CompanyId = companyId,
CustomerId = customerId,
InvoiceNumber = $"INV-0001-{id:D4}",
InvoiceDate = invoiceDate ?? DateTime.UtcNow,
Total = total,
AmountPaid = amountPaid,
Status = status
});
}
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!);
}
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
// SuperAdmin principal: bypasses the CompanyId global query filter so all
// seeded rows are visible, matching the same approach in DepositsControllerTests.
var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "SuperAdmin") }, "Test");
var principal = new ClaimsPrincipal(identity);
byte[]? noBytes = null;
var sessionMock = new Mock<ISession>();
sessionMock.Setup(s => s.TryGetValue(It.IsAny<string>(), out noBytes)).Returns(false);
var httpContextMock = new Mock<HttpContext>();
httpContextMock.SetupGet(c => c.User).Returns(principal);
httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object);
var accessor = new Mock<IHttpContextAccessor>();
accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object);
return new ApplicationDbContext(options, accessor.Object, null!);
}
}
@@ -24,24 +24,12 @@ public class ShopCapabilityCalculatorTests
} }
[Fact] [Fact]
public void GetBlastRateSqFtPerHour_WithNoCompressorCfm_ReturnsZero() public void GetBlastRateSqFtPerHour_PressurePot_Nozzle6_Paint()
{ {
// PressurePotRateByNozzle(6) = 245 * SubstrateMultiplier(Paint) 1.0 = 245
var costs = new CompanyOperatingCosts var costs = new CompanyOperatingCosts
{ {
CompressorCfm = 0m CompressorCfm = 200m,
};
var result = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(costs);
Assert.Equal(0m, result);
}
[Fact]
public void GetBlastRateSqFtPerHour_DerivesRateFromEquipmentInputs()
{
var costs = new CompanyOperatingCosts
{
CompressorCfm = 150m,
BlastNozzleSize = 6, BlastNozzleSize = 6,
BlastSetupType = BlastSetupType.PressurePot, BlastSetupType = BlastSetupType.PressurePot,
PrimaryBlastSubstrate = BlastSubstrateType.Paint PrimaryBlastSubstrate = BlastSubstrateType.Paint
@@ -49,16 +37,17 @@ public class ShopCapabilityCalculatorTests
var result = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(costs); var result = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(costs);
Assert.Equal(58.5m, result); Assert.Equal(245m, result);
} }
[Fact] [Fact]
public void GetBlastRateSqFtPerHour_ForNamedSetup_UsesSetupOverload() public void GetBlastRateSqFtPerHour_SiphonCabinet_Nozzle4_Mixed()
{ {
// SiphonCabinetRateByNozzle(4) = 125 * SubstrateMultiplier(Mixed) 0.9 = 112.5
var setup = new CompanyBlastSetup var setup = new CompanyBlastSetup
{ {
Name = "Main Cabinet", Name = "Main Cabinet",
CompressorCfm = 7m, CompressorCfm = 42m,
BlastNozzleSize = 4, BlastNozzleSize = 4,
SetupType = BlastSetupType.SiphonCabinet, SetupType = BlastSetupType.SiphonCabinet,
PrimarySubstrate = BlastSubstrateType.Mixed PrimarySubstrate = BlastSubstrateType.Mixed
@@ -66,7 +55,39 @@ public class ShopCapabilityCalculatorTests
var result = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(setup); var result = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(setup);
Assert.Equal(1.7m, result); Assert.Equal(112.5m, result);
}
[Fact]
public void GetBlastRateSqFtPerHour_PressurePot_Nozzle4_RustAndScale()
{
// PressurePotRateByNozzle(4) = 115 * SubstrateMultiplier(RustAndScale) 0.7 = 80.5
var costs = new CompanyOperatingCosts
{
BlastNozzleSize = 4,
BlastSetupType = BlastSetupType.PressurePot,
PrimaryBlastSubstrate = BlastSubstrateType.RustAndScale
};
var result = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(costs);
Assert.Equal(80.5m, result);
}
[Fact]
public void GetBlastRateSqFtPerHour_WetBlasting_Is60PctOfPressurePot()
{
// WetBlasting = PressurePotRateByNozzle(5) * 0.6 * substrate(Paint 1.0) = 175 * 0.6 = 105
var costs = new CompanyOperatingCosts
{
BlastNozzleSize = 5,
BlastSetupType = BlastSetupType.WetBlasting,
PrimaryBlastSubstrate = BlastSubstrateType.Paint
};
var result = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(costs);
Assert.Equal(105m, result);
} }
[Theory] [Theory]
@@ -86,10 +107,10 @@ public class ShopCapabilityCalculatorTests
} }
[Theory] [Theory]
[InlineData(ShopCapabilityTier.Garage, BlastSetupType.SiphonCabinet, 7, 4, BlastSubstrateType.Mixed)] [InlineData(ShopCapabilityTier.Garage, BlastSetupType.SiphonCabinet, 7, 3, BlastSubstrateType.Mixed)]
[InlineData(ShopCapabilityTier.Small, BlastSetupType.PressurePot, 40, 5, BlastSubstrateType.Mixed)] [InlineData(ShopCapabilityTier.Small, BlastSetupType.PressurePot, 49, 3, BlastSubstrateType.Mixed)]
[InlineData(ShopCapabilityTier.Medium, BlastSetupType.PressurePot, 80, 5, BlastSubstrateType.Mixed)] [InlineData(ShopCapabilityTier.Medium, BlastSetupType.PressurePot, 90, 4, BlastSubstrateType.Mixed)]
[InlineData(ShopCapabilityTier.Large, BlastSetupType.PressurePot, 150, 6, BlastSubstrateType.Mixed)] [InlineData(ShopCapabilityTier.Large, BlastSetupType.PressurePot, 150, 5, BlastSubstrateType.Mixed)]
public void TierDefaults_ReturnExpectedPresetValues( public void TierDefaults_ReturnExpectedPresetValues(
ShopCapabilityTier tier, ShopCapabilityTier tier,
BlastSetupType expectedSetup, BlastSetupType expectedSetup,