Compare commits
23 Commits
cf07356147
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 427c52a499 | |||
| d92266b027 | |||
| 750e1b1c5b | |||
| 94a89ee175 | |||
| 711cd01cd3 | |||
| 7cbae31916 | |||
| 9367e358d9 | |||
| 9f1460c9c0 | |||
| 94e536178c | |||
| 456d054229 | |||
| f38a1e3273 | |||
| 03b425a12f | |||
| 8453449833 | |||
| ad986561c9 | |||
| 0d5553f3b2 | |||
| 87bbf158a4 | |||
| f453a95f28 | |||
| d9e98a55d2 | |||
| 99deca3b62 | |||
| 23e64829bb | |||
| cd4c233b60 | |||
| 6c07216c64 | |||
| b23bea6db0 |
@@ -0,0 +1,64 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.Customer;
|
||||
|
||||
public class CustomerContactDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int CustomerId { get; set; }
|
||||
public string FirstName { get; set; } = string.Empty;
|
||||
public string? LastName { get; set; }
|
||||
public string? Title { get; set; }
|
||||
public string? ContactRole { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? MobilePhone { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public string DisplayName => string.IsNullOrWhiteSpace(LastName) ? FirstName : $"{FirstName} {LastName}";
|
||||
}
|
||||
|
||||
public class CreateCustomerContactDto
|
||||
{
|
||||
[Required(ErrorMessage = "First name is required.")]
|
||||
[StringLength(100)]
|
||||
[Display(Name = "First Name")]
|
||||
public string FirstName { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(100)]
|
||||
[Display(Name = "Last Name")]
|
||||
public string? LastName { get; set; }
|
||||
|
||||
[StringLength(100)]
|
||||
[Display(Name = "Job Title")]
|
||||
public string? Title { get; set; }
|
||||
|
||||
[StringLength(50)]
|
||||
[Display(Name = "Role")]
|
||||
public string? ContactRole { get; set; }
|
||||
|
||||
[EmailAddress]
|
||||
[StringLength(200)]
|
||||
[Display(Name = "Email")]
|
||||
public string? Email { get; set; }
|
||||
|
||||
[Phone]
|
||||
[StringLength(20)]
|
||||
[Display(Name = "Phone")]
|
||||
public string? Phone { get; set; }
|
||||
|
||||
[Phone]
|
||||
[StringLength(20)]
|
||||
[Display(Name = "Mobile Phone")]
|
||||
public string? MobilePhone { get; set; }
|
||||
|
||||
[StringLength(500)]
|
||||
[Display(Name = "Notes")]
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateCustomerContactDto : CreateCustomerContactDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int CustomerId { get; set; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -36,6 +36,16 @@ public class CustomerDto
|
||||
public bool NotifyBySms { get; set; }
|
||||
public DateTime? SmsConsentedAt { get; set; }
|
||||
public string? SmsConsentMethod { get; set; }
|
||||
|
||||
// CRM
|
||||
public string? LeadSource { get; set; }
|
||||
|
||||
// Ship-to address
|
||||
public string? ShipToAddress { get; set; }
|
||||
public string? ShipToCity { get; set; }
|
||||
public string? ShipToState { get; set; }
|
||||
public string? ShipToZipCode { get; set; }
|
||||
public string? ShipToCountry { get; set; }
|
||||
}
|
||||
|
||||
public class CreateCustomerDto : IValidatableObject
|
||||
@@ -115,6 +125,31 @@ public class CreateCustomerDto : IValidatableObject
|
||||
[StringLength(2000)]
|
||||
public string? GeneralNotes { get; set; }
|
||||
|
||||
[Display(Name = "How did you find us?")]
|
||||
[StringLength(100)]
|
||||
public string? LeadSource { get; set; }
|
||||
|
||||
// Ship-to / alternate address
|
||||
[Display(Name = "Ship-To Street Address")]
|
||||
[StringLength(500)]
|
||||
public string? ShipToAddress { get; set; }
|
||||
|
||||
[Display(Name = "City")]
|
||||
[StringLength(100)]
|
||||
public string? ShipToCity { get; set; }
|
||||
|
||||
[Display(Name = "State")]
|
||||
[StringLength(50)]
|
||||
public string? ShipToState { get; set; }
|
||||
|
||||
[Display(Name = "Zip Code")]
|
||||
[StringLength(20)]
|
||||
public string? ShipToZipCode { get; set; }
|
||||
|
||||
[Display(Name = "Country")]
|
||||
[StringLength(100)]
|
||||
public string? ShipToCountry { get; set; }
|
||||
|
||||
[Display(Name = "Notify by Email")]
|
||||
public bool NotifyByEmail { get; set; } = true;
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ public class InvoiceDto
|
||||
public string? InternalNotes { get; set; }
|
||||
public string? Terms { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public string? ExternalReference { get; set; }
|
||||
public int? SalesTaxAccountId { get; set; }
|
||||
public string? SalesTaxAccountName { get; set; }
|
||||
@@ -88,6 +89,7 @@ public class CreateInvoiceDto
|
||||
public string? InternalNotes { get; set; }
|
||||
public string? Terms { 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>
|
||||
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>
|
||||
@@ -105,6 +107,7 @@ public class UpdateInvoiceDto
|
||||
public string? InternalNotes { get; set; }
|
||||
public string? Terms { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public List<CreateInvoiceItemDto> InvoiceItems { get; set; } = new();
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ public class JobDto
|
||||
public decimal DiscountValue { get; set; }
|
||||
public string? DiscountReason { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public string? SpecialInstructions { get; set; }
|
||||
public string? InternalNotes { get; set; }
|
||||
public string? Tags { get; set; }
|
||||
@@ -113,6 +114,8 @@ public class JobListDto
|
||||
|
||||
public string? CustomerEmail { get; set; }
|
||||
public bool CustomerNotifyByEmail { get; set; } = true;
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public DateTime? ScheduledDate { get; set; }
|
||||
public DateTime? DueDate { get; set; }
|
||||
public decimal FinalPrice { get; set; }
|
||||
@@ -166,6 +169,7 @@ public class CreateJobDto
|
||||
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
|
||||
[Display(Name = "Customer PO")]
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
|
||||
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
|
||||
[Display(Name = "Special Instructions")]
|
||||
@@ -251,6 +255,7 @@ public class UpdateJobDto
|
||||
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
|
||||
[Display(Name = "Customer PO")]
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
|
||||
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
|
||||
[Display(Name = "Special Instructions")]
|
||||
|
||||
@@ -107,6 +107,7 @@ public class QuoteDto
|
||||
public string? Terms { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public string? Tags { get; set; }
|
||||
|
||||
// Items
|
||||
@@ -234,6 +235,7 @@ public class CreateQuoteDto
|
||||
[Display(Name = "Customer PO Number")]
|
||||
[StringLength(50)]
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
|
||||
[Display(Name = "Tags")]
|
||||
[StringLength(500)]
|
||||
@@ -376,6 +378,7 @@ public class UpdateQuoteDto
|
||||
[Display(Name = "Customer PO Number")]
|
||||
[StringLength(50)]
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
|
||||
[Display(Name = "Tags")]
|
||||
[StringLength(500)]
|
||||
|
||||
@@ -41,5 +41,12 @@ public class CustomerProfile : Profile
|
||||
opt => opt.MapFrom(src => !string.IsNullOrEmpty(src.ContactFirstName) || !string.IsNullOrEmpty(src.ContactLastName)
|
||||
? $"{src.ContactFirstName} {src.ContactLastName}".Trim()
|
||||
: string.Empty));
|
||||
|
||||
// CustomerContact
|
||||
CreateMap<CustomerContact, CustomerContactDto>();
|
||||
CreateMap<CreateCustomerContactDto, CustomerContact>();
|
||||
CreateMap<UpdateCustomerContactDto, CustomerContact>()
|
||||
.ForMember(dest => dest.Id, opt => opt.Ignore()); // Id is set by the controller, not mapped
|
||||
CreateMap<CustomerContact, UpdateCustomerContactDto>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ public class InvoiceProfile : Profile
|
||||
|
||||
CreateMap<Invoice, InvoiceDto>()
|
||||
.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
|
||||
? (s.Customer.IsCommercial
|
||||
? s.Customer.CompanyName
|
||||
|
||||
@@ -217,6 +217,8 @@ public class PdfService : IPdfService
|
||||
c.Item().Text($"Job #: {invoice.JobNumber}");
|
||||
if (!string.IsNullOrWhiteSpace(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);
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(quote.ProjectName))
|
||||
{
|
||||
column.Item().Row(row =>
|
||||
{
|
||||
row.ConstantItem(80).Text("Project:").FontSize(9);
|
||||
row.RelativeItem().Text(quote.ProjectName).FontSize(9);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -299,15 +299,14 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
}
|
||||
|
||||
// Custom formula items (FixedRate mode): the wizard evaluated the NCalc formula server-side
|
||||
// and stored the result as ManualUnitPrice. The formula result IS the total price — it already
|
||||
// incorporates any quantity-like fields the user entered (e.g. numWheels, numParts). Do NOT
|
||||
// multiply by Quantity again; doing so double-counts when the formula itself accounts for qty.
|
||||
// and stored the per-item result as ManualUnitPrice. Multiply by Quantity for the total,
|
||||
// exactly like every other item type that uses ManualUnitPrice.
|
||||
// SurfaceAreaSqFt mode: ManualUnitPrice is null; the formula produced sqft which was stored
|
||||
// in SurfaceAreaSqFt, so the item falls through to the standard calculated path below.
|
||||
if (item.IsCustomFormulaItem && item.ManualUnitPrice.HasValue)
|
||||
{
|
||||
var formulaTotal = item.ManualUnitPrice.Value;
|
||||
var formulaUnitPrice = item.Quantity > 0 ? formulaTotal / item.Quantity : formulaTotal;
|
||||
var formulaUnitPrice = item.ManualUnitPrice.Value;
|
||||
var formulaTotal = formulaUnitPrice * item.Quantity;
|
||||
return new QuoteItemPricingResult
|
||||
{
|
||||
MaterialCost = 0,
|
||||
|
||||
@@ -5,145 +5,165 @@ namespace PowderCoating.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// speeds) and the calculated-item wizard (to show a suggested blast time hint).
|
||||
/// Used by the AI photo quote prompt (so Claude reasons from real shop speeds)
|
||||
/// 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:
|
||||
/// BlastRate = BaseByCfm(cfm) × NozzleMultiplier × SetupMultiplier × SubstrateMultiplier
|
||||
/// Both pressure pots and siphon cabinets are nozzle-primary: nozzle size
|
||||
/// 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.
|
||||
/// All multipliers are relative to that baseline.
|
||||
/// Sources:
|
||||
/// 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>
|
||||
public static class ShopCapabilityCalculator
|
||||
{
|
||||
// ── Blast rate derivation ─────────────────────────────────────────────────
|
||||
// ── Public entry points ────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns the effective blast rate in sqft/hr.
|
||||
/// If <see cref="CompanyOperatingCosts.BlastRateSqFtPerHourOverride"/> is set, returns it directly.
|
||||
/// Otherwise derives from CFM, nozzle, setup type, and substrate.
|
||||
/// Returns 0 when CFM is not configured (shop hasn't calibrated yet).
|
||||
/// Returns the effective blast rate in sqft/hr for company-level operating costs.
|
||||
/// BlastRateSqFtPerHourOverride bypasses the formula when set.
|
||||
/// </summary>
|
||||
public static decimal GetBlastRateSqFtPerHour(CompanyOperatingCosts costs)
|
||||
{
|
||||
if (costs.BlastRateSqFtPerHourOverride.HasValue && costs.BlastRateSqFtPerHourOverride.Value > 0)
|
||||
return costs.BlastRateSqFtPerHourOverride.Value;
|
||||
|
||||
if (costs.CompressorCfm <= 0)
|
||||
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);
|
||||
return CalculateBlastRate(costs.BlastNozzleSize, costs.BlastSetupType, costs.PrimaryBlastSubstrate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the effective blast rate in sqft/hr for a named <see cref="CompanyBlastSetup"/>.
|
||||
/// Identical logic to the <see cref="CompanyOperatingCosts"/> overload — uses override if set,
|
||||
/// otherwise derives from the setup's equipment specs.
|
||||
/// Returns the effective blast rate in sqft/hr for a named blast setup.
|
||||
/// BlastRateSqFtPerHourOverride bypasses the formula when set.
|
||||
/// </summary>
|
||||
public static decimal GetBlastRateSqFtPerHour(CompanyBlastSetup setup)
|
||||
{
|
||||
if (setup.BlastRateSqFtPerHourOverride.HasValue && setup.BlastRateSqFtPerHourOverride.Value > 0)
|
||||
return setup.BlastRateSqFtPerHourOverride.Value;
|
||||
|
||||
if (setup.CompressorCfm <= 0)
|
||||
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);
|
||||
return CalculateBlastRate(setup.BlastNozzleSize, setup.SetupType, setup.PrimarySubstrate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the effective coating application rate in sqft/hr.
|
||||
/// If override is set, returns it directly.
|
||||
/// Otherwise derives a sensible default from gun type.
|
||||
/// Override bypasses the formula when set.
|
||||
/// </summary>
|
||||
public static decimal GetCoatingRateSqFtPerHour(CompanyOperatingCosts costs)
|
||||
{
|
||||
if (costs.CoatingRateSqFtPerHourOverride.HasValue && costs.CoatingRateSqFtPerHourOverride.Value > 0)
|
||||
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
|
||||
{
|
||||
CoatingGunType.Corona => 40m,
|
||||
CoatingGunType.Tribo => 35m, // slower on flat but better on complex; conservative default
|
||||
CoatingGunType.Tribo => 35m,
|
||||
CoatingGunType.Both => 40m,
|
||||
_ => 40m
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns default equipment field values for a given capability tier.
|
||||
/// Applied during Setup Wizard tier selection so the shop gets reasonable
|
||||
/// starting values even if they never visit the Quoting Calibration tab.
|
||||
/// Returns default equipment field values for a given capability tier, applied
|
||||
/// during Setup Wizard tier selection so new shops get reasonable starting values.
|
||||
/// 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>
|
||||
public static (BlastSetupType SetupType, decimal Cfm, int NozzleSize, BlastSubstrateType Substrate)
|
||||
TierDefaults(ShopCapabilityTier tier) => tier switch
|
||||
{
|
||||
ShopCapabilityTier.Garage => (BlastSetupType.SiphonCabinet, 7m, 4, BlastSubstrateType.Mixed),
|
||||
ShopCapabilityTier.Small => (BlastSetupType.PressurePot, 40m, 5, BlastSubstrateType.Mixed),
|
||||
ShopCapabilityTier.Medium => (BlastSetupType.PressurePot, 80m, 5, BlastSubstrateType.Mixed),
|
||||
ShopCapabilityTier.Large => (BlastSetupType.PressurePot, 150m, 6, BlastSubstrateType.Mixed),
|
||||
_ => (BlastSetupType.SiphonCabinet, 7m, 4, BlastSubstrateType.Mixed)
|
||||
ShopCapabilityTier.Garage => (BlastSetupType.SiphonCabinet, 7m, 3, BlastSubstrateType.Mixed),
|
||||
ShopCapabilityTier.Small => (BlastSetupType.PressurePot, 49m, 3, BlastSubstrateType.Mixed),
|
||||
ShopCapabilityTier.Medium => (BlastSetupType.PressurePot, 90m, 4, BlastSubstrateType.Mixed),
|
||||
ShopCapabilityTier.Large => (BlastSetupType.PressurePot, 150m, 5, BlastSubstrateType.Mixed),
|
||||
_ => (BlastSetupType.SiphonCabinet, 7m, 3, BlastSubstrateType.Mixed)
|
||||
};
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
// ── Core formula (single path for all callers) ─────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Base sqft/hr at a pressure pot, #5 nozzle, removing paint.
|
||||
/// Calibrated so that real-world examples produce expected results:
|
||||
/// - 7 CFM siphon cabinet → ~2 sqft/hr (garage coater, 3+ hrs/wheel)
|
||||
/// - 40 CFM pressure pot → ~15 sqft/hr (small shop, ~30 min/wheel)
|
||||
/// - 80 CFM pressure pot → ~25 sqft/hr (medium shop)
|
||||
/// - 150 CFM pressure pot → ~40 sqft/hr (large shop, ~10 min/wheel)
|
||||
/// Nozzle-primary blast rate calculation. Nozzle size determines throughput;
|
||||
/// setup type routes to the appropriate reference table; substrate adjusts for
|
||||
/// removal difficulty. CFM is not used — it is a consequence of nozzle choice,
|
||||
/// not an independent variable in throughput.
|
||||
/// </summary>
|
||||
private static decimal BaseByCfm(decimal cfm) => cfm switch
|
||||
private static decimal CalculateBlastRate(int nozzle, BlastSetupType setupType, BlastSubstrateType substrate)
|
||||
{
|
||||
< 10 => 5m,
|
||||
< 20 => 9m,
|
||||
< 40 => 15m,
|
||||
< 80 => 25m,
|
||||
< 120 => 35m,
|
||||
_ => 45m
|
||||
var baseRate = setupType switch
|
||||
{
|
||||
BlastSetupType.PressurePot => PressurePotRateByNozzle(nozzle),
|
||||
BlastSetupType.SiphonCabinet => SiphonCabinetRateByNozzle(nozzle),
|
||||
// Siphon pot: open gravity feed, no enclosure penalty, ~80% of pressure pot
|
||||
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
|
||||
};
|
||||
|
||||
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
|
||||
{
|
||||
1 => 20m,
|
||||
2 => 40m,
|
||||
3 => 75m,
|
||||
4 => 115m,
|
||||
5 => 175m,
|
||||
6 => 245m,
|
||||
7 => 325m,
|
||||
8 => 430m,
|
||||
_ => 100m
|
||||
};
|
||||
|
||||
private static decimal NozzleMultiplier(int nozzle) => nozzle 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
|
||||
{
|
||||
2 => 0.35m,
|
||||
3 => 0.55m,
|
||||
4 => 0.75m,
|
||||
5 => 1.00m,
|
||||
6 => 1.30m,
|
||||
7 => 1.65m,
|
||||
8 => 2.00m,
|
||||
_ => 1.00m
|
||||
};
|
||||
|
||||
private static decimal SetupMultiplier(BlastSetupType setup) => setup switch
|
||||
{
|
||||
BlastSetupType.SiphonCabinet => 0.50m, // enclosed, low pressure, repositioning time
|
||||
BlastSetupType.SiphonPot => 0.70m,
|
||||
BlastSetupType.PressurePot => 1.00m, // baseline
|
||||
BlastSetupType.WetBlasting => 0.60m,
|
||||
_ => 1.00m
|
||||
1 => 18m,
|
||||
2 => 38m,
|
||||
3 => 75m,
|
||||
4 => 125m,
|
||||
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
|
||||
{
|
||||
BlastSubstrateType.PowderCoat => 1.25m, // faster to remove than paint
|
||||
BlastSubstrateType.Paint => 1.00m, // baseline
|
||||
BlastSubstrateType.PowderCoat => 1.25m,
|
||||
BlastSubstrateType.Paint => 1.00m,
|
||||
BlastSubstrateType.Mixed => 0.90m,
|
||||
BlastSubstrateType.RustAndScale => 0.70m, // requires more passes
|
||||
BlastSubstrateType.RustAndScale => 0.70m,
|
||||
_ => 0.90m
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,6 +41,17 @@ public class Customer : BaseEntity
|
||||
public bool IsActive { get; set; } = true;
|
||||
public DateTime? LastContactDate { get; set; }
|
||||
|
||||
// CRM fields
|
||||
/// <summary>How the customer found the shop (Walk-In, Google Search, Customer Referral, etc.).</summary>
|
||||
public string? LeadSource { get; set; }
|
||||
|
||||
// Ship-to / alternate address (separate from billing address above)
|
||||
public string? ShipToAddress { get; set; }
|
||||
public string? ShipToCity { get; set; }
|
||||
public string? ShipToState { get; set; }
|
||||
public string? ShipToZipCode { get; set; }
|
||||
public string? ShipToCountry { get; set; }
|
||||
|
||||
// Notification preferences
|
||||
public bool NotifyByEmail { get; set; } = true;
|
||||
// NotifyBySms is only set to true after explicit staff-recorded consent (TCPA compliance)
|
||||
@@ -55,4 +66,5 @@ public class Customer : BaseEntity
|
||||
|
||||
public virtual ICollection<NotificationLog> NotificationLogs { get; set; } = new List<NotificationLog>();
|
||||
public virtual ICollection<Invoice> Invoices { get; set; } = new List<Invoice>();
|
||||
public virtual ICollection<CustomerContact> CustomerContacts { get; set; } = new List<CustomerContact>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PowderCoating.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// An additional contact person associated with a customer account.
|
||||
/// Commercial customers frequently have separate billing, operations, and drop-off contacts.
|
||||
/// The primary contact remains on the Customer entity; these are supplementary.
|
||||
/// </summary>
|
||||
public class CustomerContact : BaseEntity
|
||||
{
|
||||
public int CustomerId { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string FirstName { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(100)]
|
||||
public string? LastName { get; set; }
|
||||
|
||||
/// <summary>Job title / role at the company, e.g. "Purchasing Manager".</summary>
|
||||
[StringLength(100)]
|
||||
public string? Title { get; set; }
|
||||
|
||||
/// <summary>Functional role: Billing, Operations, Drop-off, Sales, General, etc.</summary>
|
||||
[StringLength(50)]
|
||||
public string? ContactRole { get; set; }
|
||||
|
||||
[StringLength(200)]
|
||||
public string? Email { get; set; }
|
||||
|
||||
[StringLength(20)]
|
||||
public string? Phone { get; set; }
|
||||
|
||||
[StringLength(20)]
|
||||
public string? MobilePhone { get; set; }
|
||||
|
||||
[StringLength(500)]
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public virtual Customer? Customer { get; set; }
|
||||
}
|
||||
@@ -48,6 +48,7 @@ public class Invoice : BaseEntity
|
||||
public string? InternalNotes { get; set; }
|
||||
public string? Terms { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Early payment discount percentage (e.g., 2 means 2% discount).
|
||||
|
||||
@@ -47,6 +47,7 @@ public class Job : BaseEntity
|
||||
|
||||
// Additional Information
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public string? SpecialInstructions { get; set; }
|
||||
public string? InternalNotes { get; set; } // Internal notes from quote
|
||||
public string? Tags { get; set; }
|
||||
|
||||
@@ -88,6 +88,7 @@ public class Quote : BaseEntity
|
||||
public string? Terms { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public string? Tags { get; set; }
|
||||
|
||||
// Conversion tracking
|
||||
|
||||
@@ -152,6 +152,20 @@ public class CustomerNote : BaseEntity
|
||||
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 int JobId { get; set; }
|
||||
|
||||
@@ -43,6 +43,8 @@ public interface IUnitOfWork : IDisposable
|
||||
IJobPhotoRepository JobPhotos { get; }
|
||||
IRepository<JobNote> JobNotes { get; }
|
||||
IRepository<CustomerNote> CustomerNotes { get; }
|
||||
IRepository<CustomerContact> CustomerContacts { get; }
|
||||
IRepository<CustomerPreferredPowder> CustomerPreferredPowders { get; }
|
||||
IRepository<JobStatusHistory> JobStatusHistory { get; }
|
||||
IRepository<PricingTier> PricingTiers { get; }
|
||||
|
||||
|
||||
@@ -230,6 +230,10 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
public DbSet<JobNote> JobNotes { get; set; }
|
||||
/// <summary>Free-text notes added to a customer record by staff; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<CustomerNote> CustomerNotes { get; set; }
|
||||
/// <summary>Additional contacts (billing, ops, drop-off) associated with a customer; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<CustomerContact> CustomerContacts { 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>
|
||||
public DbSet<JobStatusHistory> JobStatusHistory { get; set; }
|
||||
/// <summary>Customer pricing tiers (Standard, Preferred, Premium); tenant-filtered with soft delete.</summary>
|
||||
@@ -551,6 +555,8 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<CustomerNote>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<CustomerPreferredPowder>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<JobStatusHistory>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
||||
@@ -1719,6 +1725,23 @@ modelBuilder.Entity<Job>()
|
||||
.HasIndex(cn => new { cn.CustomerId, cn.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
|
||||
// ===================================================================
|
||||
|
||||
src/PowderCoating.Infrastructure/Migrations/20260608182208_AddProjectNameToQuotesAndJobs.Designer.cs
Generated
+11165
File diff suppressed because it is too large
Load Diff
+81
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+11168
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+11239
File diff suppressed because it is too large
Load Diff
+110
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+11345
File diff suppressed because it is too large
Load Diff
+164
@@ -0,0 +1,164 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCustomerContactsAndCrmFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "LeadSource",
|
||||
table: "Customers",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ShipToAddress",
|
||||
table: "Customers",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ShipToCity",
|
||||
table: "Customers",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ShipToCountry",
|
||||
table: "Customers",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ShipToState",
|
||||
table: "Customers",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ShipToZipCode",
|
||||
table: "Customers",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CustomerContacts",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
CustomerId = table.Column<int>(type: "int", nullable: false),
|
||||
FirstName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
LastName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
|
||||
Title = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
|
||||
ContactRole = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
|
||||
Email = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
Phone = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
|
||||
MobilePhone = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
|
||||
Notes = table.Column<string>(type: "nvarchar(500)", maxLength: 500, 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_CustomerContacts", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_CustomerContacts_Customers_CustomerId",
|
||||
column: x => x.CustomerId,
|
||||
principalTable: "Customers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9129));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9137));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9138));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CustomerContacts_CustomerId",
|
||||
table: "CustomerContacts",
|
||||
column: "CustomerId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "CustomerContacts");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LeadSource",
|
||||
table: "Customers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ShipToAddress",
|
||||
table: "Customers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ShipToCity",
|
||||
table: "Customers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ShipToCountry",
|
||||
table: "Customers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ShipToState",
|
||||
table: "Customers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ShipToZipCode",
|
||||
table: "Customers");
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2818,6 +2818,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<DateTime?>("LastContactDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("LeadSource")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("MobilePhone")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -2836,6 +2839,21 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<int?>("PricingTierId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("ShipToAddress")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ShipToCity")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ShipToCountry")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ShipToState")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ShipToZipCode")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("SmsConsentMethod")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -2894,6 +2912,81 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.ToTable("Customers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerContact", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("ContactRole")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
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<string>("Email")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("LastName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("MobilePhone")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CustomerId");
|
||||
|
||||
b.ToTable("CustomerContacts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerNote", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -2944,6 +3037,58 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -4269,6 +4414,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("PreparedById")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("ProjectName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("PublicViewToken")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -4560,6 +4708,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("PricingBreakdownJson")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ProjectName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("QuoteId")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -7053,7 +7204,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9377),
|
||||
CreatedAt = new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9129),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -7064,7 +7215,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9381),
|
||||
CreatedAt = new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9137),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -7075,7 +7226,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9382),
|
||||
CreatedAt = new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9138),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
@@ -7385,6 +7536,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<decimal>("ProfitPercent")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("ProjectName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ProspectAddress")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -9474,6 +9628,17 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Navigation("PricingTier");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerContact", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.Customer", "Customer")
|
||||
.WithMany("CustomerContacts")
|
||||
.HasForeignKey("CustomerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Customer");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerNote", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.Customer", "Customer")
|
||||
@@ -9485,6 +9650,25 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.Invoice", "AppliedToInvoice")
|
||||
@@ -10980,6 +11164,8 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b =>
|
||||
{
|
||||
b.Navigation("CustomerContacts");
|
||||
|
||||
b.Navigation("CustomerNotes");
|
||||
|
||||
b.Navigation("Invoices");
|
||||
|
||||
@@ -70,6 +70,8 @@ public class UnitOfWork : IUnitOfWork
|
||||
private IJobPhotoRepository? _jobPhotos;
|
||||
private IRepository<JobNote>? _jobNotes;
|
||||
private IRepository<CustomerNote>? _customerNotes;
|
||||
private IRepository<CustomerContact>? _customerContacts;
|
||||
private IRepository<CustomerPreferredPowder>? _customerPreferredPowders;
|
||||
private IRepository<JobStatusHistory>? _jobStatusHistory;
|
||||
private IRepository<PricingTier>? _pricingTiers;
|
||||
|
||||
@@ -321,6 +323,11 @@ public class UnitOfWork : IUnitOfWork
|
||||
/// <summary>Repository for <see cref="CustomerNote"/> free-text staff notes on customer records; tenant-filtered with soft delete.</summary>
|
||||
public IRepository<CustomerNote> CustomerNotes =>
|
||||
_customerNotes ??= new Repository<CustomerNote>(_context);
|
||||
/// <summary>Repository for <see cref="CustomerContact"/> additional contacts (billing, ops, drop-off) on commercial accounts; tenant-filtered with soft delete.</summary>
|
||||
public IRepository<CustomerContact> CustomerContacts =>
|
||||
_customerContacts ??= new Repository<CustomerContact>(_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>
|
||||
public IRepository<JobStatusHistory> JobStatusHistory =>
|
||||
|
||||
@@ -94,7 +94,7 @@ public class NotificationService : INotificationService
|
||||
quote.CompanyId, NotificationType.QuoteSent, values,
|
||||
$"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 (success, error) = await _emailService.SendEmailAsync(
|
||||
@@ -137,7 +137,7 @@ public class NotificationService : INotificationService
|
||||
quote.CompanyId, NotificationType.QuoteSent, values,
|
||||
$"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 (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||
@@ -300,7 +300,7 @@ public class NotificationService : INotificationService
|
||||
quote.CompanyId, NotificationType.QuoteApproved, values,
|
||||
$"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 (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||
@@ -383,7 +383,7 @@ public class NotificationService : INotificationService
|
||||
var (subject, htmlBody) = await GetRenderedEmailAsync(
|
||||
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 (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||
@@ -451,7 +451,7 @@ public class NotificationService : INotificationService
|
||||
job.CompanyId, NotificationType.JobCompleted, values,
|
||||
$"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 (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)
|
||||
? StripHtml(htmlBody) + $"\r\n\r\nPay online: {paymentUrl}"
|
||||
: StripHtml(fullHtml);
|
||||
@@ -793,7 +793,7 @@ public class NotificationService : INotificationService
|
||||
invoice.CompanyId, NotificationType.PaymentReceived, values,
|
||||
$"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 (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||
@@ -867,7 +867,7 @@ public class NotificationService : INotificationService
|
||||
invoice.CompanyId, NotificationType.PaymentReminder, values,
|
||||
$"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 (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||
@@ -971,7 +971,7 @@ public class NotificationService : INotificationService
|
||||
var (subject, htmlBody) = await GetRenderedEmailAsync(
|
||||
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 (success, error) = await _emailService.SendEmailAsync(
|
||||
@@ -1218,7 +1218,7 @@ public class NotificationService : INotificationService
|
||||
var (custSubject, custHtml) = await GetRenderedEmailAsync(
|
||||
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 (custOk, custErr, custLog) = await SendToEmailListAsync(
|
||||
@@ -1388,17 +1388,25 @@ public class NotificationService : INotificationService
|
||||
/// <summary>
|
||||
/// Appends CAN-SPAM required footer as HTML.
|
||||
/// </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 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;
|
||||
|
||||
var footer = "<hr style=\"border: none; border-top: 1px solid #eee; margin: 24px 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)
|
||||
{
|
||||
var addressLine = BuildAddressLine(company!);
|
||||
@@ -1535,7 +1543,15 @@ public class NotificationService : INotificationService
|
||||
.AsNoTracking()
|
||||
.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>
|
||||
|
||||
@@ -1726,6 +1726,26 @@ public class CompanySettingsController : Controller
|
||||
|
||||
#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>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetBlastSetups()
|
||||
@@ -3043,6 +3063,151 @@ public class CompanySettingsController : Controller
|
||||
return Json(new { success = true, templates = dtos });
|
||||
}
|
||||
|
||||
/// <summary>Downloads all formula templates as a portable JSON backup file.</summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> ExportCustomItemTemplates()
|
||||
{
|
||||
if (!AllowCustomFormulas()) return Forbid();
|
||||
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||
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
|
||||
{
|
||||
exportedAt = DateTime.UtcNow,
|
||||
version = 1,
|
||||
templates = templates
|
||||
.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name)
|
||||
.Select(t => new
|
||||
{
|
||||
t.Name,
|
||||
t.Description,
|
||||
t.OutputMode,
|
||||
Fields = ParseFields(t.FieldsJson),
|
||||
t.Formula,
|
||||
t.DefaultRate,
|
||||
t.RateLabel,
|
||||
t.Notes,
|
||||
t.DisplayOrder,
|
||||
t.IsActive
|
||||
})
|
||||
};
|
||||
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(export,
|
||||
new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase
|
||||
});
|
||||
var filename = $"formula-templates-{DateTime.UtcNow:yyyyMMdd}.json";
|
||||
return File(System.Text.Encoding.UTF8.GetBytes(json), "application/json", filename);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports formula templates from a JSON backup file produced by ExportCustomItemTemplates.
|
||||
/// Templates whose name already exists in the company are skipped; all others are created.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> ImportCustomItemTemplates(IFormFile file)
|
||||
{
|
||||
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
|
||||
if (file == null || file.Length == 0) return Json(new { success = false, message = "No file selected." });
|
||||
if (!file.FileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
return Json(new { success = false, message = "File must be a .json export file." });
|
||||
if (file.Length > 512 * 1024)
|
||||
return Json(new { success = false, message = "File is too large (max 512 KB)." });
|
||||
|
||||
string json;
|
||||
using (var reader = new System.IO.StreamReader(file.OpenReadStream()))
|
||||
json = await reader.ReadToEndAsync();
|
||||
|
||||
System.Text.Json.JsonElement root;
|
||||
try
|
||||
{
|
||||
root = System.Text.Json.JsonDocument.Parse(json).RootElement;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Json(new { success = false, message = "Could not parse file — make sure it is a valid formula export." });
|
||||
}
|
||||
|
||||
if (!root.TryGetProperty("templates", out var templatesEl) || templatesEl.ValueKind != System.Text.Json.JsonValueKind.Array)
|
||||
return Json(new { success = false, message = "Invalid export format: missing \"templates\" array." });
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||
var existing = await _unitOfWork.CustomItemTemplates.FindAsync(t => t.CompanyId == companyId);
|
||||
// Track names already in DB + names imported within this same file to prevent intra-file duplicates
|
||||
var usedNames = existing.Select(t => t.Name.ToLowerInvariant()).ToHashSet();
|
||||
|
||||
int imported = 0, skipped = 0;
|
||||
var skippedNames = new List<string>();
|
||||
var errors = new List<string>();
|
||||
|
||||
foreach (var item in templatesEl.EnumerateArray())
|
||||
{
|
||||
try
|
||||
{
|
||||
var name = item.TryGetProperty("name", out var nEl) ? nEl.GetString() ?? "" : "";
|
||||
if (string.IsNullOrWhiteSpace(name)) { errors.Add("Skipped one template with no name."); continue; }
|
||||
|
||||
if (usedNames.Contains(name.ToLowerInvariant()))
|
||||
{
|
||||
skipped++;
|
||||
skippedNames.Add(name);
|
||||
continue;
|
||||
}
|
||||
|
||||
var dto = new CreateCustomItemTemplateDto
|
||||
{
|
||||
Name = name,
|
||||
Description = item.TryGetProperty("description", out var d) ? d.GetString() : null,
|
||||
OutputMode = item.TryGetProperty("outputMode", out var om) ? om.GetString() ?? "FixedRate" : "FixedRate",
|
||||
// "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() ?? "" : "",
|
||||
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,
|
||||
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,
|
||||
IsActive = true,
|
||||
};
|
||||
|
||||
var fieldError = ValidateTemplateFields(dto.FieldsJson);
|
||||
if (fieldError != null) { errors.Add($"\"{name}\": {fieldError}"); continue; }
|
||||
|
||||
var (normalizedFormula, formulaError) = _formulaAiService.NormalizeAndValidate(dto.Formula);
|
||||
if (formulaError != null) { errors.Add($"\"{name}\": formula error — {formulaError}"); continue; }
|
||||
dto.Formula = normalizedFormula;
|
||||
|
||||
var entity = _mapper.Map<CustomItemTemplate>(dto);
|
||||
entity.CompanyId = companyId;
|
||||
entity.CreatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.CustomItemTemplates.AddAsync(entity);
|
||||
|
||||
usedNames.Add(name.ToLowerInvariant());
|
||||
imported++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"Unexpected error on one template: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (imported > 0)
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return Json(new { success = true, imported, skipped, skippedNames, errors });
|
||||
}
|
||||
|
||||
/// <summary>Creates a new formula template for the current company.</summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> CreateCustomItemTemplate([FromBody] CreateCustomItemTemplateDto dto)
|
||||
|
||||
@@ -144,9 +144,11 @@ public class CustomersController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the customer detail page, including the 10 most-recent non-voided credit memos.
|
||||
/// Credit memos are loaded separately (not via eager loading) because the customer entity
|
||||
/// does not navigate to CreditMemo; this keeps the Customer aggregate lean.
|
||||
/// Renders the customer detail page. In addition to basic info and credit memos, runs
|
||||
/// four sequential queries (jobs, quotes, invoices, deposits) to build:
|
||||
/// (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>
|
||||
public async Task<IActionResult> Details(int? id)
|
||||
{
|
||||
@@ -170,6 +172,120 @@ public class CustomersController : Controller
|
||||
.Take(10)
|
||||
.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;
|
||||
|
||||
var customerContacts = (await _unitOfWork.CustomerContacts.FindAsync(n => n.CustomerId == id.Value))
|
||||
.OrderBy(c => c.FirstName)
|
||||
.ToList();
|
||||
ViewBag.CustomerContacts = customerContacts;
|
||||
|
||||
// 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);
|
||||
return View(customerDto);
|
||||
}
|
||||
@@ -938,6 +1054,308 @@ public class CustomersController : Controller
|
||||
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"">★</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)} — {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." });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Customer Contacts ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns the JSON representation of a single contact for pre-populating the edit modal.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetContact(int id, int contactId)
|
||||
{
|
||||
var contact = await _unitOfWork.CustomerContacts.GetByIdAsync(contactId);
|
||||
if (contact == null || contact.CustomerId != id)
|
||||
return Json(new { success = false });
|
||||
|
||||
var dto = _mapper.Map<PowderCoating.Application.DTOs.Customer.UpdateCustomerContactDto>(contact);
|
||||
return Json(new { success = true, contact = dto });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new contact to the customer record. Returns rendered row HTML so the
|
||||
/// caller can append it to the contacts table without a full reload.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> AddContact(int id, PowderCoating.Application.DTOs.Customer.CreateCustomerContactDto dto)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return Json(new { success = false, message = ModelState.Values.SelectMany(v => v.Errors).FirstOrDefault()?.ErrorMessage ?? "Invalid data." });
|
||||
|
||||
try
|
||||
{
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
|
||||
if (customer == null) return Json(new { success = false, message = "Customer not found." });
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var entity = _mapper.Map<PowderCoating.Core.Entities.CustomerContact>(dto);
|
||||
entity.CustomerId = id;
|
||||
entity.CompanyId = companyId;
|
||||
|
||||
await _unitOfWork.CustomerContacts.AddAsync(entity);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
var rowHtml = BuildContactRowHtml(id, entity);
|
||||
return Json(new { success = true, contactId = entity.Id, rowHtml });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error adding contact to customer {CustomerId}", id);
|
||||
return Json(new { success = false, message = "An error occurred." });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing contact record in place. Returns the updated row HTML.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> UpdateContact(int id, PowderCoating.Application.DTOs.Customer.UpdateCustomerContactDto dto)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return Json(new { success = false, message = ModelState.Values.SelectMany(v => v.Errors).FirstOrDefault()?.ErrorMessage ?? "Invalid data." });
|
||||
|
||||
try
|
||||
{
|
||||
var contact = await _unitOfWork.CustomerContacts.GetByIdAsync(dto.Id);
|
||||
if (contact == null || contact.CustomerId != id)
|
||||
return Json(new { success = false, message = "Contact not found." });
|
||||
|
||||
_mapper.Map(dto, contact);
|
||||
contact.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _unitOfWork.CustomerContacts.UpdateAsync(contact);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
var rowHtml = BuildContactRowHtml(id, contact);
|
||||
return Json(new { success = true, contactId = contact.Id, rowHtml });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error updating contact {ContactId} for customer {CustomerId}", dto.Id, id);
|
||||
return Json(new { success = false, message = "An error occurred." });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Soft-deletes a contact. Only the owning company can delete their contacts
|
||||
/// (enforced via CompanyId + global query filter).
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> DeleteContact(int id, int contactId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var contact = await _unitOfWork.CustomerContacts.GetByIdAsync(contactId);
|
||||
if (contact == null || contact.CustomerId != id)
|
||||
return Json(new { success = false, message = "Contact not found." });
|
||||
|
||||
await _unitOfWork.CustomerContacts.SoftDeleteAsync(contact);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return Json(new { success = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting contact {ContactId} for customer {CustomerId}", contactId, id);
|
||||
return Json(new { success = false, message = "An error occurred." });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the table-row HTML for a contact. Kept server-side so the same markup is
|
||||
/// used for both the initial page render and the AJAX insert/replace path.
|
||||
/// </summary>
|
||||
private static string BuildContactRowHtml(int customerId, PowderCoating.Core.Entities.CustomerContact c)
|
||||
{
|
||||
var displayName = string.IsNullOrWhiteSpace(c.LastName) ? c.FirstName : $"{c.FirstName} {c.LastName}";
|
||||
var titlePart = !string.IsNullOrWhiteSpace(c.Title) ? System.Web.HttpUtility.HtmlEncode(c.Title) : "";
|
||||
var roleBadge = !string.IsNullOrWhiteSpace(c.ContactRole)
|
||||
? $"<span class=\"badge bg-secondary bg-opacity-10 text-secondary ms-1\">{System.Web.HttpUtility.HtmlEncode(c.ContactRole)}</span>"
|
||||
: "";
|
||||
var email = !string.IsNullOrWhiteSpace(c.Email)
|
||||
? $"<a href=\"mailto:{System.Web.HttpUtility.HtmlEncode(c.Email)}\" class=\"text-decoration-none small\">{System.Web.HttpUtility.HtmlEncode(c.Email)}</a>"
|
||||
: "<span class=\"text-muted small\">—</span>";
|
||||
var phone = !string.IsNullOrWhiteSpace(c.Phone ?? c.MobilePhone)
|
||||
? $"<span class=\"small\">{System.Web.HttpUtility.HtmlEncode(c.Phone ?? c.MobilePhone)}</span>"
|
||||
: "<span class=\"text-muted small\">—</span>";
|
||||
|
||||
return $@"<tr data-contact-id=""{c.Id}"">
|
||||
<td>
|
||||
<div class=""fw-semibold"">{System.Web.HttpUtility.HtmlEncode(displayName)}{roleBadge}</div>
|
||||
{(string.IsNullOrWhiteSpace(titlePart) ? "" : $"<div class=\"text-muted\" style=\"font-size:0.75rem;\">{titlePart}</div>")}
|
||||
</td>
|
||||
<td>{email}</td>
|
||||
<td>{phone}</td>
|
||||
<td class=""text-end"">
|
||||
<button type=""button"" class=""btn btn-sm btn-outline-secondary me-1""
|
||||
onclick=""editContact({customerId}, {c.Id})"" title=""Edit"">
|
||||
<i class=""bi bi-pencil""></i>
|
||||
</button>
|
||||
<button type=""button"" class=""btn btn-sm btn-outline-danger""
|
||||
onclick=""deleteContact({customerId}, {c.Id})"" title=""Delete"">
|
||||
<i class=""bi bi-trash""></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
|
||||
@@ -1642,8 +1642,10 @@ public class InventoryController : Controller
|
||||
|
||||
var userId = _userManager.GetUserId(User);
|
||||
|
||||
var recentCutoff = DateTime.UtcNow.AddDays(-7);
|
||||
|
||||
var myJobs = (await _unitOfWork.Jobs.FindAsync(
|
||||
j => !j.JobStatus.IsTerminalStatus && j.AssignedUserId == userId,
|
||||
j => (!j.JobStatus.IsTerminalStatus || j.UpdatedAt >= recentCutoff) && j.AssignedUserId == userId,
|
||||
false,
|
||||
j => j.Customer,
|
||||
j => j.JobStatus))
|
||||
@@ -1651,7 +1653,7 @@ public class InventoryController : Controller
|
||||
.Select(j => new ScanJobOption
|
||||
{
|
||||
Id = j.Id,
|
||||
JobNumber = j.JobNumber,
|
||||
JobNumber = j.JobNumber + (j.JobStatus.IsTerminalStatus ? " (completed)" : ""),
|
||||
CustomerName = j.Customer != null
|
||||
? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName)
|
||||
: "No Customer"
|
||||
@@ -1660,7 +1662,7 @@ public class InventoryController : Controller
|
||||
|
||||
var myJobIds = myJobs.Select(j => j.Id).ToHashSet();
|
||||
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,
|
||||
j => j.Customer,
|
||||
j => j.JobStatus))
|
||||
@@ -1669,7 +1671,7 @@ public class InventoryController : Controller
|
||||
.Select(j => new ScanJobOption
|
||||
{
|
||||
Id = j.Id,
|
||||
JobNumber = j.JobNumber,
|
||||
JobNumber = j.JobNumber + (j.JobStatus.IsTerminalStatus ? " (completed)" : ""),
|
||||
CustomerName = j.Customer != null
|
||||
? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName)
|
||||
: "No Customer"
|
||||
@@ -1686,9 +1688,64 @@ public class InventoryController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records powder usage logged via the mobile scan page. Creates a JobUsage
|
||||
/// InventoryTransaction (and PowderUsageLog) when a job is selected, or an
|
||||
/// Adjustment transaction when logging without a job. Updates QuantityOnHand.
|
||||
/// Core inventory usage recording logic shared by LogUsage (scan page) and LogMaterial (modal).
|
||||
/// Deducts quantityUsed from QuantityOnHand, writes an InventoryTransaction, and posts GL entries.
|
||||
/// </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>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
@@ -1697,55 +1754,26 @@ public class InventoryController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var item = await _unitOfWork.InventoryItems.GetByIdAsync(inventoryItemId);
|
||||
if (item == null) return NotFound();
|
||||
|
||||
if (quantity <= 0)
|
||||
{
|
||||
TempData["ScanError"] = "Quantity must be greater than zero.";
|
||||
return RedirectToAction(nameof(Scan), new { id = inventoryItemId });
|
||||
}
|
||||
|
||||
var userId = _userManager.GetUserId(User) ?? string.Empty;
|
||||
// Scan-based logging always records as JobUsage; Adjustment is for manual stock corrections only
|
||||
var txnType = InventoryTransactionType.JobUsage;
|
||||
var result = await RecordInventoryUsageAsync(
|
||||
inventoryItemId, jobId, quantity,
|
||||
InventoryTransactionType.JobUsage, notes);
|
||||
|
||||
item.QuantityOnHand -= quantity;
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.InventoryItems.UpdateAsync(item);
|
||||
|
||||
var txn = new InventoryTransaction
|
||||
if (!result.Success)
|
||||
{
|
||||
InventoryItemId = item.Id,
|
||||
TransactionType = txnType,
|
||||
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);
|
||||
TempData["ScanError"] = result.Message;
|
||||
return RedirectToAction(nameof(Scan), new { id = inventoryItemId });
|
||||
}
|
||||
|
||||
// PowderUsageLog requires a specific JobItem + Coat FK — scan-based logging
|
||||
// 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["ScanSuccess"] = result.Message;
|
||||
TempData["ScanItemId"] = inventoryItemId.ToString();
|
||||
TempData["ScanJobId"] = jobId?.ToString();
|
||||
TempData["ScanItemName"] = item.Name;
|
||||
TempData["ScanItemName"] = result.ItemName;
|
||||
return RedirectToAction(nameof(ScanSuccess));
|
||||
}
|
||||
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>
|
||||
/// Success screen shown after a usage log is saved. Offers "Log Another Item for
|
||||
/// This Job" and "Done" options.
|
||||
@@ -2003,7 +2068,7 @@ public class InventoryController : Controller
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetUsageForEdit(int id)
|
||||
@@ -2034,10 +2099,27 @@ public class InventoryController : Controller
|
||||
})
|
||||
.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
|
||||
{
|
||||
transactionId = txn.Id,
|
||||
jobId = txn.JobId,
|
||||
quantity = Math.Abs(txn.Quantity),
|
||||
notes = txn.Notes,
|
||||
transactionDate = txn.TransactionDate.ToString("yyyy-MM-ddTHH:mm"),
|
||||
itemName = txn.InventoryItem?.Name,
|
||||
@@ -2046,14 +2128,15 @@ public class InventoryController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves edits to a JobUsage InventoryTransaction's job assignment, notes, and date.
|
||||
/// Quantity and balance are not changed.
|
||||
/// Saves edits to a JobUsage InventoryTransaction: job assignment, quantity, notes, and date.
|
||||
/// When quantity changes the InventoryItem.QuantityOnHand is adjusted by the delta so the
|
||||
/// ledger balance remains consistent.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[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.TransactionType != InventoryTransactionType.JobUsage
|
||||
&& txn.TransactionType != InventoryTransactionType.Adjustment)
|
||||
@@ -2075,6 +2158,28 @@ public class InventoryController : Controller
|
||||
if (jobId.HasValue && txn.TransactionType == InventoryTransactionType.Adjustment)
|
||||
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.TransactionDate = transactionDate.Kind == DateTimeKind.Utc
|
||||
? transactionDate : DateTime.SpecifyKind(transactionDate, DateTimeKind.Utc);
|
||||
@@ -2094,3 +2199,21 @@ public class ScanJobOption
|
||||
public string JobNumber { 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.CustomerId = job.CustomerId;
|
||||
dto.CustomerPO = job.CustomerPO;
|
||||
dto.ProjectName = job.ProjectName;
|
||||
|
||||
// Resolve catalog item revenue accounts for pre-population
|
||||
var catalogItemIds = job.JobItems
|
||||
@@ -710,6 +711,7 @@ public class InvoicesController : Controller
|
||||
InternalNotes = dto.InternalNotes,
|
||||
Terms = dto.Terms,
|
||||
CustomerPO = dto.CustomerPO,
|
||||
ProjectName = dto.ProjectName,
|
||||
CompanyId = currentUser.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CreatedBy = currentUser.Email
|
||||
@@ -901,6 +903,7 @@ public class InvoicesController : Controller
|
||||
InternalNotes = invoice.InternalNotes,
|
||||
Terms = invoice.Terms,
|
||||
CustomerPO = invoice.CustomerPO,
|
||||
ProjectName = invoice.ProjectName ?? invoice.Job?.ProjectName,
|
||||
InvoiceItems = invoice.InvoiceItems
|
||||
.Where(i => !i.IsDeleted)
|
||||
.OrderBy(i => i.DisplayOrder)
|
||||
@@ -1036,6 +1039,7 @@ public class InvoicesController : Controller
|
||||
invoice.InternalNotes = dto.InternalNotes;
|
||||
invoice.Terms = dto.Terms;
|
||||
invoice.CustomerPO = dto.CustomerPO;
|
||||
invoice.ProjectName = dto.ProjectName;
|
||||
invoice.UpdatedAt = DateTime.UtcNow;
|
||||
invoice.UpdatedBy = currentUser?.Email;
|
||||
|
||||
|
||||
@@ -152,6 +152,10 @@ public class JobsController : Controller
|
||||
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup
|
||||
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Delivered;
|
||||
}
|
||||
else if (statusGroup == "ready")
|
||||
{
|
||||
filter = j => j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup;
|
||||
}
|
||||
// "all" or unknown group: no filter applied (show every status)
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
@@ -1981,6 +1985,146 @@ public class JobsController : Controller
|
||||
}
|
||||
|
||||
/// <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} — 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).
|
||||
/// 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.
|
||||
@@ -4399,75 +4543,7 @@ public class JobsController : Controller
|
||||
_logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logs manual material usage from the job details page. Mirrors the QR scan LogUsage
|
||||
/// flow in InventoryController but returns JSON so the modal can close and refresh inline.
|
||||
/// Quantity is always the amount USED (caller converts from remaining if needed).
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> LogMaterial([FromBody] LogMaterialRequest req)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (req.QuantityUsed <= 0)
|
||||
return Json(new { success = false, message = "Quantity used must be greater than zero." });
|
||||
|
||||
var item = await _unitOfWork.InventoryItems.GetByIdAsync(req.InventoryItemId);
|
||||
if (item == null) return Json(new { success = false, message = "Inventory item not found." });
|
||||
|
||||
var job = await _unitOfWork.Jobs.GetByIdAsync(req.JobId);
|
||||
if (job == null) return Json(new { success = false, message = "Job not found." });
|
||||
|
||||
var txnType = req.TransactionType == "Waste"
|
||||
? InventoryTransactionType.Waste
|
||||
: InventoryTransactionType.JobUsage;
|
||||
|
||||
item.QuantityOnHand -= req.QuantityUsed;
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.InventoryItems.UpdateAsync(item);
|
||||
|
||||
var txn = new PowderCoating.Core.Entities.InventoryTransaction
|
||||
{
|
||||
InventoryItemId = item.Id,
|
||||
TransactionType = txnType,
|
||||
Quantity = -req.QuantityUsed,
|
||||
UnitCost = item.UnitCost,
|
||||
TotalCost = req.QuantityUsed * item.UnitCost,
|
||||
TransactionDate = DateTime.UtcNow,
|
||||
BalanceAfter = item.QuantityOnHand,
|
||||
JobId = req.JobId,
|
||||
Reference = $"Job {job.JobNumber}",
|
||||
Notes = req.Notes?.Trim(),
|
||||
CompanyId = item.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
await _unitOfWork.InventoryTransactions.AddAsync(txn);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// GL: DR COGS, CR Inventory Asset
|
||||
if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue)
|
||||
{
|
||||
var cost = req.QuantityUsed * (item.AverageCost > 0 ? item.AverageCost : item.UnitCost);
|
||||
await _accountBalanceService.DebitAsync(item.CogsAccountId, cost);
|
||||
await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
|
||||
}
|
||||
|
||||
return Json(new
|
||||
{
|
||||
success = true,
|
||||
message = $"Logged {req.QuantityUsed:N2} {item.UnitOfMeasure} of {item.Name}.",
|
||||
newBalance = item.QuantityOnHand,
|
||||
unitOfMeasure = item.UnitOfMeasure,
|
||||
itemName = item.Name
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error logging material for job {JobId}", req.JobId);
|
||||
return Json(new { success = false, message = "An error occurred. Please try again." });
|
||||
}
|
||||
}
|
||||
// LogMaterial has been consolidated into InventoryController.LogMaterial.
|
||||
|
||||
/// <summary>
|
||||
/// Inline-edits description, quantity, and unit price on a single job line item.
|
||||
@@ -4554,14 +4630,6 @@ public class PatchJobItemRequest
|
||||
public decimal Quantity { 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 int ReworkRecordId { get; set; }
|
||||
|
||||
@@ -1957,12 +1957,10 @@ public class QuotesController : Controller
|
||||
if (dto.SmsConsent)
|
||||
await _notificationService.NotifySmsConsentGrantedAsync(customer);
|
||||
|
||||
// Get "Converted" status (cached)
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var statuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId);
|
||||
var convertedStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Converted);
|
||||
|
||||
// Update quote to link to new customer
|
||||
// Update quote to link to new customer.
|
||||
// Do NOT set "Converted" status here — that status is reserved for when a job is
|
||||
// actually created via CreateJobFromQuote. Keeping the quote at "Approved" lets the
|
||||
// user immediately click "Create Job from Quote" on the next screen.
|
||||
quote.CustomerId = customer.Id;
|
||||
|
||||
// Clear prospect fields
|
||||
@@ -1977,14 +1975,11 @@ public class QuotesController : Controller
|
||||
quote.ProspectSmsConsent = false;
|
||||
quote.ProspectSmsConsentedAt = null;
|
||||
|
||||
// Update status to converted
|
||||
quote.QuoteStatusId = convertedStatus?.Id ?? quote.QuoteStatusId;
|
||||
|
||||
await _unitOfWork.Quotes.UpdateAsync(quote);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
this.ToastSuccess($"Prospect/Walk-In successfully converted to customer! Quote {quote.QuoteNumber} has been updated.");
|
||||
return RedirectToAction("Details", "Customers", new { id = customer.Id });
|
||||
this.ToastSuccess($"Customer record created! You can now create a job from quote {quote.QuoteNumber}.");
|
||||
return RedirectToAction(nameof(Details), new { id = dto.QuoteId });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -2958,6 +2953,7 @@ public class QuotesController : Controller
|
||||
Total = quote.Total
|
||||
}),
|
||||
CustomerPO = quote.CustomerPO,
|
||||
ProjectName = quote.ProjectName,
|
||||
InternalNotes = quote.Notes, // Copy internal notes from quote
|
||||
IsCustomerApproved = true,
|
||||
IsRushJob = quote.IsRushJob,
|
||||
@@ -3435,13 +3431,21 @@ public class QuotesController : Controller
|
||||
// Build company AI context: profile text + recent accepted predictions as few-shot examples
|
||||
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;
|
||||
if (request.BlastSetupId.HasValue)
|
||||
{
|
||||
var setups = await _unitOfWork.BlastSetups.FindAsync(b => b.Id == request.BlastSetupId.Value && b.IsActive && b.CompanyId == companyId);
|
||||
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);
|
||||
await _usageLogger.LogAsync(companyId, user?.Id ?? "", AppConstants.AiFeatures.PhotoQuote, result.Success, photos.Sum(p => p.Data.Length));
|
||||
|
||||
@@ -153,7 +153,7 @@ public static class HelpKnowledgeBase
|
||||
- *Commercial*: Businesses. Can have a pricing tier, credit limit, tax exempt status, and linked quotes/jobs.
|
||||
- *Non-Commercial*: Individual consumers. Simpler setup.
|
||||
|
||||
**Key fields:** Name, email, phone, address, customer type, pricing tier, credit limit, tax exempt (with certificate upload), notes.
|
||||
**Key fields:** Name, email, phone, address, customer type, pricing tier, credit limit, tax exempt (with certificate upload), notes, lead source, ship-to address.
|
||||
|
||||
**How to add a customer:**
|
||||
1. Go to [Customers](/Customers)
|
||||
@@ -161,10 +161,20 @@ public static class HelpKnowledgeBase
|
||||
3. Fill in name, contact info, select type
|
||||
4. Save
|
||||
|
||||
**Customer Details page** (/Customers/Details/ID) shows: contact info, all linked jobs, quotes, invoices, deposits, balance, notes.
|
||||
**Customer Details page** (/Customers/Details/ID) shows: contact info, all linked jobs, quotes, invoices, deposits, balance, notes, additional contacts.
|
||||
|
||||
**Customer Notes:** Add internal notes on the Details page. Notes are private (not visible to the customer).
|
||||
|
||||
**Additional Contacts:** Store billing contacts, ops contacts, drop-off contacts, etc. on the Customer Details page. These are for staff reference only — all automated notifications (emails, SMS) go to the primary email/phone on the main customer record, not to additional contacts. If invoices need to go to a separate address, use the Billing Email field on the main record.
|
||||
|
||||
**Lead Source:** Optional field on the customer record indicating how they found the shop (Walk-In, Google Search, Customer Referral, Social Media, Website, Repeat Customer, Trade Show, Flyer/Print, Other).
|
||||
|
||||
**Ship-To Address:** Optional separate address for pickups or deliveries. Shown alongside the billing address on the Customer Details page when set.
|
||||
|
||||
**Preferred Powders:** On the Customer Details page, the Preferred Powders card lets staff tag inventory items that a customer regularly orders. Use the search box to find a powder by name or SKU and click Add. Remove with the × button. This is a staff-reference tool only — it does not auto-select powders on quotes or jobs. Items must already exist in Inventory to appear in the search.
|
||||
|
||||
**Outstanding Pickups (Ready for Pickup card):** When one or more of a customer's jobs are in "Ready for Pickup" status, a highlighted card appears on their Customer Details page showing each job number and how many days it has been waiting. Color coding: amber = 3–6 days, red = 7+ days. The card disappears once all jobs move out of Ready for Pickup status. Useful for front desk staff to instantly see during a call whether parts are ready for this customer.
|
||||
|
||||
**Deactivating a customer:** Use the Delete/Deactivate option — this soft-deletes (hides) the customer but does not erase data.
|
||||
|
||||
**Pricing Tiers:** Assign a tier (configured at [Pricing Tiers](/PricingTiers)) to automatically apply a discount to that customer's quotes.
|
||||
@@ -194,8 +204,9 @@ public static class HelpKnowledgeBase
|
||||
2. Choose Quick Quote (fast) or Full Quote (complete form) using the toggle at the top
|
||||
3. Select existing customer OR enter prospect info (name, email, phone)
|
||||
4. Add line items using the item wizard (3 item types below)
|
||||
5. Review the pricing breakdown
|
||||
6. Save as Draft or Send immediately
|
||||
5. Optionally enter a **Project Name** — a short label (e.g. "Shop Equipment Rack") that carries through to the job and invoice when the quote is converted.
|
||||
6. Review the pricing breakdown
|
||||
7. Save as Draft or Send immediately
|
||||
|
||||
**Item types in the quote/job wizard:**
|
||||
1. *Product from Catalog* — pick a pre-priced catalog item; price is fixed, no surface-area calculation
|
||||
@@ -286,8 +297,9 @@ public static class HelpKnowledgeBase
|
||||
2. Select customer
|
||||
3. Add line items (same wizard as quotes: Calculated, Custom Work, or AI Photo)
|
||||
4. Set priority, due date, assigned worker, special instructions
|
||||
5. Optionally set Oven & Batch Settings — select a named oven, number of batches, and cycle time. These affect the oven cost in pricing.
|
||||
6. Save
|
||||
5. Optionally enter a **Project Name** — a short label (e.g. "Front Gate Panels") that appears on the job, linked invoice, and printed documents to help the customer identify what the work is for.
|
||||
6. Optionally set Oven & Batch Settings — select a named oven, number of batches, and cycle time. These affect the oven cost in pricing.
|
||||
7. Save
|
||||
|
||||
**Job Priority Board:** [/JobsPriority](/JobsPriority) — Kanban-style view of all active jobs sorted by priority and status.
|
||||
|
||||
@@ -328,6 +340,8 @@ public static class HelpKnowledgeBase
|
||||
|
||||
**Job Templates:** [/JobTemplates](/JobTemplates) — Save a job's items as a template to reuse for common work types. When creating a new job, select a template to pre-fill items.
|
||||
|
||||
**Cloning a Job:** On any Job Details page, click the **Clone Job** button (copy icon in the header toolbar). The system creates a new draft job immediately and redirects you to it. The clone copies: customer, description, PO number, project name, special instructions, tags, priority, discount %, oven settings, and all line items with their coats and prep services. It does NOT copy: due date, scheduled date, assigned worker, photos, notes, time entries, status history, or any linked invoice or payments. The clone starts in Pending status so it goes through the normal workflow.
|
||||
|
||||
**Changing the customer on a job:** On the Job Details page, the Customer field is an always-visible dropdown. Select a different customer — a confirmation banner appears. Click **Save** to apply or **Cancel** to revert. Use this to correct a misassigned job or to move a walk-in job to a customer's proper record after they've been added to the system.
|
||||
|
||||
**Inline item price editing:** On the Job Details page, any unit price, quantity, or item description can be edited in-place without opening the full edit form. Click the value — it becomes an input field. Type the new value, then press Enter or click away to save (Escape cancels). The pricing summary card (Items Subtotal, Subtotal, Tax, and Total) and the Job Costing card both update immediately without a page reload.
|
||||
@@ -377,8 +391,10 @@ public static class HelpKnowledgeBase
|
||||
- *Voided* — cancelled invoice
|
||||
- *Written Off* — uncollectable, written off
|
||||
|
||||
**Project Name on invoices:** If the linked job had a Project Name set, it auto-fills on the invoice and appears on the printed PDF to help the customer identify the work.
|
||||
|
||||
**How to create an invoice:**
|
||||
1. From the Job Details page → "Create Invoice" (recommended — pre-fills all items), OR
|
||||
1. From the Job Details page → "Create Invoice" (recommended — pre-fills all items including Project Name), OR
|
||||
2. Go to [Invoices](/Invoices) → "New Invoice" and select a job
|
||||
|
||||
**Recording a payment:**
|
||||
@@ -470,6 +486,8 @@ public static class HelpKnowledgeBase
|
||||
- **Low Stock** (red) — quantity is greater than zero but at or below the reorder point; time to reorder
|
||||
- **Out of Stock** (dark/black) — quantity is zero; an alert banner appears on the Details page
|
||||
|
||||
**Low Stock stat card (clickable filter):** The "Low Stock" KPI card at the top of the Inventory page is clickable. Click it to instantly filter the list to only items needing reorder. Click it again (or clear the filter banner) to return to the full list. This is the fastest way to generate a reorder checklist.
|
||||
|
||||
**Stock Adjustment:** From Inventory Details, click "Stock Adjustment" to open the quick-adjust modal. Choose Add Stock, Remove Stock, or Set Exact, enter the quantity, select a reason (required), and optionally add notes. Every adjustment is automatically recorded as a transaction with the reason and notes included.
|
||||
|
||||
**Inventory transactions:** Every stock movement is recorded automatically — Initial (item creation), Purchase (PO receipt), Adjustment (manual or edit), Job Usage (powder consumed on a job coat), Sale, Return, Waste, Transfer. Each record stores date, quantity delta, unit cost, and running balance after the change.
|
||||
|
||||
@@ -2197,6 +2197,15 @@
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="cfShowWalkthrough()">
|
||||
<i class="bi bi-question-circle me-1"></i>How it works
|
||||
</button>
|
||||
<a href="/CompanySettings/ExportCustomItemTemplates"
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
title="Download all templates as a JSON backup file">
|
||||
<i class="bi bi-download me-1"></i>Export
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="cfShowImport()"
|
||||
title="Restore templates from a JSON backup file">
|
||||
<i class="bi bi-upload me-1"></i>Import
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" onclick="cfShowCreate()">
|
||||
<i class="bi bi-plus-circle me-1"></i>New Template
|
||||
</button>
|
||||
@@ -2281,6 +2290,40 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Formula Import Modal -->
|
||||
<div class="modal fade" id="cfImportModal" tabindex="-1" aria-labelledby="cfImportModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="cfImportModalLabel">
|
||||
<i class="bi bi-upload me-2"></i>Import Formula Templates
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted small mb-3">
|
||||
Select a <code>.json</code> file previously exported from this page.
|
||||
Templates whose name already exists in your account will be skipped.
|
||||
</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Backup file <span class="text-danger">*</span></label>
|
||||
<input type="file" id="cfImportFile" class="form-control" accept=".json" />
|
||||
</div>
|
||||
<div id="cfImportResults" class="d-none">
|
||||
<hr />
|
||||
<div id="cfImportSummary"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-primary" id="cfImportBtn" onclick="cfSubmitImport()">
|
||||
<i class="bi bi-upload me-1"></i>Import
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Formula Walkthrough Modal -->
|
||||
<div class="modal fade" id="cfWalkthroughModal" tabindex="-1" aria-labelledby="cfWalkthroughLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
@@ -91,7 +91,7 @@
|
||||
<label asp-for="BillingEmail" class="form-label">Billing / Accounting Email
|
||||
<span class="text-muted fw-normal">(invoices sent here)</span>
|
||||
</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>
|
||||
<div class="form-text">When set, invoices are emailed here instead of the contact email.</div>
|
||||
</div>
|
||||
@@ -209,6 +209,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ship-To Address Section -->
|
||||
<div class="mb-4">
|
||||
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
|
||||
<h5 class="mb-0"><i class="bi bi-truck me-2 text-primary"></i>Ship-To / Pickup Address</h5>
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Ship-To Address"
|
||||
data-bs-content="Optional. Fill in only if this customer picks up or receives deliveries at a different address than their billing address. Leave blank to use the billing address above.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
<span class="text-muted small fw-normal">(optional — leave blank if same as billing)</span>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label asp-for="ShipToAddress" class="form-label">Street Address</label>
|
||||
<input asp-for="ShipToAddress" class="form-control" placeholder="Enter ship-to street address" />
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label asp-for="ShipToCity" class="form-label">City</label>
|
||||
<input asp-for="ShipToCity" class="form-control" placeholder="Enter city" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label asp-for="ShipToState" class="form-label">State</label>
|
||||
<input asp-for="ShipToState" class="form-control" placeholder="Enter state" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label asp-for="ShipToZipCode" class="form-label">Zip Code</label>
|
||||
<input asp-for="ShipToZipCode" class="form-control" placeholder="12345" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label asp-for="ShipToCountry" class="form-label">Country</label>
|
||||
<input asp-for="ShipToCountry" class="form-control" placeholder="USA" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Business Information Section -->
|
||||
<div class="mb-4">
|
||||
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
|
||||
@@ -282,6 +318,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lead Source Section -->
|
||||
<div class="mb-4">
|
||||
<h5 class="border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-signpost me-2 text-primary"></i>How Did They Find Us?
|
||||
</h5>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label asp-for="LeadSource" class="form-label">Lead Source</label>
|
||||
<select asp-for="LeadSource" class="form-select">
|
||||
<option value="">— Not specified —</option>
|
||||
<option value="Walk-In">Walk-In</option>
|
||||
<option value="Google Search">Google Search</option>
|
||||
<option value="Customer Referral">Customer Referral</option>
|
||||
<option value="Social Media">Social Media</option>
|
||||
<option value="Website">Website</option>
|
||||
<option value="Repeat Customer">Repeat Customer</option>
|
||||
<option value="Trade Show / Event">Trade Show / Event</option>
|
||||
<option value="Flyer / Print Ad">Flyer / Print Ad</option>
|
||||
<option value="Other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes Section -->
|
||||
<div class="mb-4">
|
||||
<h5 class="border-bottom pb-2 mb-3">
|
||||
|
||||
@@ -216,27 +216,50 @@
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (!string.IsNullOrEmpty(Model.Address))
|
||||
@{
|
||||
bool hasBilling = !string.IsNullOrEmpty(Model.Address);
|
||||
bool hasShipTo = !string.IsNullOrEmpty(Model.ShipToAddress) || !string.IsNullOrEmpty(Model.ShipToCity);
|
||||
}
|
||||
@if (hasShipTo)
|
||||
{
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="text-muted small mb-1">Billing Address</label>
|
||||
@if (hasBilling)
|
||||
{
|
||||
<p class="mb-1">@Model.Address</p>
|
||||
<p class="mb-0">
|
||||
@if (!string.IsNullOrEmpty(Model.City)) { <span>@Model.City</span> }
|
||||
@if (!string.IsNullOrEmpty(Model.State)) { <span>, @Model.State</span> }
|
||||
@if (!string.IsNullOrEmpty(Model.ZipCode)) { <span> @Model.ZipCode</span> }
|
||||
</p>
|
||||
@if (!string.IsNullOrEmpty(Model.Country)) { <p class="mb-0 text-muted">@Model.Country</p> }
|
||||
}
|
||||
else { <p class="text-muted mb-0">Not provided</p> }
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="text-muted small mb-1">
|
||||
<i class="bi bi-truck me-1"></i>Ship-To / Pickup Address
|
||||
</label>
|
||||
<p class="mb-1">@Model.ShipToAddress</p>
|
||||
<p class="mb-0">
|
||||
@if (!string.IsNullOrEmpty(Model.ShipToCity)) { <span>@Model.ShipToCity</span> }
|
||||
@if (!string.IsNullOrEmpty(Model.ShipToState)) { <span>, @Model.ShipToState</span> }
|
||||
@if (!string.IsNullOrEmpty(Model.ShipToZipCode)) { <span> @Model.ShipToZipCode</span> }
|
||||
</p>
|
||||
@if (!string.IsNullOrEmpty(Model.ShipToCountry)) { <p class="mb-0 text-muted">@Model.ShipToCountry</p> }
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (hasBilling)
|
||||
{
|
||||
<p class="mb-2">@Model.Address</p>
|
||||
<p class="mb-0">
|
||||
@if (!string.IsNullOrEmpty(Model.City))
|
||||
{
|
||||
<span>@Model.City</span>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Model.State))
|
||||
{
|
||||
<span>, @Model.State</span>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Model.ZipCode))
|
||||
{
|
||||
<span> @Model.ZipCode</span>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Model.City)) { <span>@Model.City</span> }
|
||||
@if (!string.IsNullOrEmpty(Model.State)) { <span>, @Model.State</span> }
|
||||
@if (!string.IsNullOrEmpty(Model.ZipCode)) { <span> @Model.ZipCode</span> }
|
||||
</p>
|
||||
@if (!string.IsNullOrEmpty(Model.Country))
|
||||
{
|
||||
<p class="mb-0 text-muted">@Model.Country</p>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Model.Country)) { <p class="mb-0 text-muted">@Model.Country</p> }
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -262,6 +285,15 @@
|
||||
<label class="text-muted small mb-1">Payment Terms</label>
|
||||
<p class="mb-0">@(Model.PaymentTerms ?? "Not set")</p>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(Model.LeadSource))
|
||||
{
|
||||
<div class="col-md-6">
|
||||
<label class="text-muted small mb-1">Lead Source</label>
|
||||
<p class="mb-0">
|
||||
<i class="bi bi-signpost me-1 text-muted"></i>@Model.LeadSource
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
<div class="col-md-6">
|
||||
<label class="text-muted small mb-1">Credit Limit</label>
|
||||
<p class="mb-0 fw-semibold">@Model.CreditLimit.ToString("C")</p>
|
||||
@@ -328,6 +360,211 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Additional Contacts -->
|
||||
@{
|
||||
var customerContacts = ViewBag.CustomerContacts as List<PowderCoating.Core.Entities.CustomerContact>;
|
||||
}
|
||||
<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-people me-2 text-primary"></i>Additional Contacts
|
||||
</h5>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="text-muted small">
|
||||
<i class="bi bi-info-circle me-1"></i>For staff reference — automated notifications still go to the primary contact above.
|
||||
</span>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary"
|
||||
data-bs-toggle="modal" data-bs-target="#contactModal"
|
||||
onclick="openAddContactModal()">
|
||||
<i class="bi bi-plus-circle me-1"></i>Add Contact
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="ps-3">Name / Role</th>
|
||||
<th>Email</th>
|
||||
<th>Phone</th>
|
||||
<th class="text-end pe-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="contacts-table-body">
|
||||
@if (customerContacts != null && customerContacts.Count > 0)
|
||||
{
|
||||
@foreach (var c in customerContacts)
|
||||
{
|
||||
var displayName = string.IsNullOrWhiteSpace(c.LastName) ? c.FirstName : $"{c.FirstName} {c.LastName}";
|
||||
<tr data-contact-id="@c.Id">
|
||||
<td class="ps-3">
|
||||
<div class="fw-semibold">
|
||||
@displayName
|
||||
@if (!string.IsNullOrEmpty(c.ContactRole))
|
||||
{
|
||||
<span class="badge bg-secondary bg-opacity-10 text-secondary ms-1">@c.ContactRole</span>
|
||||
}
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(c.Title))
|
||||
{
|
||||
<div class="text-muted" style="font-size:0.75rem;">@c.Title</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (!string.IsNullOrEmpty(c.Email))
|
||||
{
|
||||
<a href="mailto:@c.Email" class="text-decoration-none small">@c.Email</a>
|
||||
}
|
||||
else { <span class="text-muted small">—</span> }
|
||||
</td>
|
||||
<td>
|
||||
@if (!string.IsNullOrEmpty(c.Phone ?? c.MobilePhone))
|
||||
{
|
||||
<span class="small">@(c.Phone ?? c.MobilePhone)</span>
|
||||
}
|
||||
else { <span class="text-muted small">—</span> }
|
||||
</td>
|
||||
<td class="text-end pe-3">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary me-1"
|
||||
onclick="editContact(@Model.Id, @c.Id)" title="Edit">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger"
|
||||
onclick="deleteContact(@Model.Id, @c.Id)" title="Delete">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<tr id="no-contacts-placeholder">
|
||||
<td colspan="4" class="text-muted small px-3 py-2">No additional contacts. Click “Add Contact” to add billing, ops, or drop-off contacts.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</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">★</span>
|
||||
}
|
||||
<span class="note-text small">@note.Note</span>
|
||||
<div class="text-muted" style="font-size:0.75rem;">
|
||||
@(note.CreatedBy ?? "Staff") — @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">★</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>
|
||||
|
||||
<!-- Right Column - Statistics -->
|
||||
@@ -378,6 +615,41 @@
|
||||
</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 -->
|
||||
@{
|
||||
var creditMemos = ViewBag.CreditMemos as List<PowderCoating.Core.Entities.CreditMemo>;
|
||||
@@ -430,30 +702,139 @@
|
||||
</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-header bg-white border-0 py-3">
|
||||
<h5 class="mb-0 fw-semibold">
|
||||
<i class="bi bi-bar-chart-line me-2 text-primary"></i>Customer Stats
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Jobs row -->
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-6 text-center p-2" style="border-right:1px solid #dee2e6;">
|
||||
<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 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
|
||||
{
|
||||
<span>Last job @crmStats.DaysSinceLastJob days ago</span>
|
||||
}
|
||||
}
|
||||
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 class="text-muted small mt-1">
|
||||
<i class="bi bi-person me-1"></i>Customer since @Model.CreatedAt.ToString("MMM yyyy")
|
||||
</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-clock-history me-2 text-primary"></i>Activity
|
||||
<i class="bi bi-droplet-fill me-2 text-primary"></i>Preferred Powders
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="text-muted small mb-1">Last Contact</label>
|
||||
<p class="mb-0">
|
||||
@if (Model.LastContactDate.HasValue)
|
||||
<div class="card-body p-0">
|
||||
<div id="preferred-powders-list">
|
||||
@if (preferredPowders != null && preferredPowders.Count > 0)
|
||||
{
|
||||
@foreach (var p in preferredPowders)
|
||||
{
|
||||
<span>@Model.LastContactDate.Value.ToString("MMMM dd, yyyy")</span>
|
||||
<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">— @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">×</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">No contact recorded</span>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div id="no-powders-placeholder" class="px-3 py-2 text-muted small">No preferred powders yet.</div>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-muted small mb-1">Customer Since</label>
|
||||
<p class="mb-0">@Model.CreatedAt.ToString("MMMM dd, yyyy")</p>
|
||||
<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 p-0"
|
||||
style="display:none;position:absolute;z-index:1000;max-height:200px;overflow-y:auto;"></div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<input type="text" id="powderNotes" class="form-control form-control-sm"
|
||||
placeholder="Optional notes (e.g. "customer prefers this for wheels")"
|
||||
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>
|
||||
@@ -482,6 +863,17 @@
|
||||
<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
|
||||
</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'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">
|
||||
<i class="bi bi-file-text me-2"></i>New Quote
|
||||
</a>
|
||||
@@ -502,6 +894,72 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add / Edit Contact Modal -->
|
||||
<div class="modal fade" id="contactModal" tabindex="-1" aria-labelledby="contactModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="contactModalLabel">
|
||||
<i class="bi bi-person-plus me-2 text-primary"></i><span id="contactModalTitle">Add Contact</span>
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="contactId" value="0" />
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">First Name <span class="text-danger">*</span></label>
|
||||
<input type="text" id="contactFirstName" class="form-control" maxlength="100" placeholder="First name" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Last Name</label>
|
||||
<input type="text" id="contactLastName" class="form-control" maxlength="100" placeholder="Last name" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Job Title</label>
|
||||
<input type="text" id="contactTitle" class="form-control" maxlength="100" placeholder="e.g. Purchasing Manager" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Role</label>
|
||||
<select id="contactRole" class="form-select">
|
||||
<option value="">— Select —</option>
|
||||
<option value="Billing">Billing</option>
|
||||
<option value="Operations">Operations</option>
|
||||
<option value="Drop-Off">Drop-Off</option>
|
||||
<option value="Sales">Sales</option>
|
||||
<option value="General">General</option>
|
||||
<option value="Other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Email</label>
|
||||
<input type="email" id="contactEmail" class="form-control" maxlength="200" placeholder="email@example.com" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Phone</label>
|
||||
<input type="tel" id="contactPhone" class="form-control" maxlength="20" placeholder="(555) 123-4567" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Mobile Phone</label>
|
||||
<input type="tel" id="contactMobilePhone" class="form-control" maxlength="20" placeholder="(555) 123-4567" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Notes</label>
|
||||
<textarea id="contactNotes" class="form-control" rows="2" maxlength="500" placeholder="Optional notes about this contact..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div id="contactModalError" class="alert alert-danger alert-permanent mt-3 d-none"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveContact(@Model.Id)" id="saveContactBtn">
|
||||
<i class="bi bi-check-circle me-1"></i>Save Contact
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Store Credit Modal -->
|
||||
@if (User.IsInRole("SuperAdmin") || User.IsInRole("Administrator"))
|
||||
{
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<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>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
@@ -95,7 +95,7 @@
|
||||
<label asp-for="BillingEmail" class="form-label">Billing / Accounting Email
|
||||
<span class="text-muted fw-normal">(invoices sent here)</span>
|
||||
</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>
|
||||
<div class="form-text">When set, invoices are emailed here instead of the contact email.</div>
|
||||
</div>
|
||||
@@ -213,6 +213,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ship-To Address Section -->
|
||||
<div class="mb-4">
|
||||
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
|
||||
<h5 class="mb-0"><i class="bi bi-truck me-2 text-primary"></i>Ship-To / Pickup Address</h5>
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Ship-To Address"
|
||||
data-bs-content="Optional. Fill in only if this customer picks up or receives deliveries at a different address than their billing address. Leave blank to use the billing address above.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
<span class="text-muted small fw-normal">(optional — leave blank if same as billing)</span>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label asp-for="ShipToAddress" class="form-label">Street Address</label>
|
||||
<input asp-for="ShipToAddress" class="form-control" placeholder="Enter ship-to street address" />
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label asp-for="ShipToCity" class="form-label">City</label>
|
||||
<input asp-for="ShipToCity" class="form-control" placeholder="Enter city" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label asp-for="ShipToState" class="form-label">State</label>
|
||||
<input asp-for="ShipToState" class="form-control" placeholder="Enter state" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label asp-for="ShipToZipCode" class="form-label">Zip Code</label>
|
||||
<input asp-for="ShipToZipCode" class="form-control" placeholder="12345" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label asp-for="ShipToCountry" class="form-label">Country</label>
|
||||
<input asp-for="ShipToCountry" class="form-control" placeholder="USA" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Business Information Section -->
|
||||
<div class="mb-4">
|
||||
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
|
||||
@@ -270,6 +306,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lead Source Section -->
|
||||
<div class="mb-4">
|
||||
<h5 class="border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-signpost me-2 text-primary"></i>How Did They Find Us?
|
||||
</h5>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label asp-for="LeadSource" class="form-label">Lead Source</label>
|
||||
<select asp-for="LeadSource" class="form-select">
|
||||
<option value="">— Not specified —</option>
|
||||
<option value="Walk-In">Walk-In</option>
|
||||
<option value="Google Search">Google Search</option>
|
||||
<option value="Customer Referral">Customer Referral</option>
|
||||
<option value="Social Media">Social Media</option>
|
||||
<option value="Website">Website</option>
|
||||
<option value="Repeat Customer">Repeat Customer</option>
|
||||
<option value="Trade Show / Event">Trade Show / Event</option>
|
||||
<option value="Flyer / Print Ad">Flyer / Print Ad</option>
|
||||
<option value="Other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes Section -->
|
||||
<div class="mb-4">
|
||||
<h5 class="border-bottom pb-2 mb-3">
|
||||
|
||||
@@ -136,8 +136,26 @@
|
||||
</p>
|
||||
<p>The details page shows:</p>
|
||||
<ul>
|
||||
<li><strong>Contact information</strong> — name, email, phone, and address.</li>
|
||||
<li><strong>Account summary</strong> — current balance, credit limit, and pricing tier.</li>
|
||||
<li><strong>Contact information</strong> — name, email, phone, address, and lead source.</li>
|
||||
<li><strong>Account summary</strong> — current balance, credit limit, store credit, and pricing tier.</li>
|
||||
<li>
|
||||
<strong>Ready for Pickup</strong> — if any of this customer’s jobs are in “Ready for Pickup” status,
|
||||
a highlighted card appears in the right column showing each job number and how many days it has been waiting.
|
||||
Jobs waiting 3–6 days show in amber; 7+ days in red.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Additional Contacts</strong> — billing contacts, ops contacts, drop-off contacts, and so on.
|
||||
See the Additional Contacts section below.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Internal Notes</strong> — private notes added by your staff (not visible to the customer).
|
||||
Notes can be marked as important <span class="text-warning">★</span> to highlight them for the team.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Preferred Powders</strong> — inventory items this customer frequently uses. Staff can
|
||||
search and add powders here so that anyone creating a quote or job for this customer can quickly
|
||||
see which colors they prefer. See the Preferred Powders section below.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Jobs tab</strong> — every job created for this customer, with status and date. Click
|
||||
a job number to open it.
|
||||
@@ -153,7 +171,7 @@
|
||||
<li>
|
||||
<strong>Deposits tab</strong> — all deposits recorded for this customer across any job or quote.
|
||||
</li>
|
||||
<li><strong>Notes</strong> — any notes saved against the customer record.</li>
|
||||
<li><strong>Recent Activity</strong> — a combined timeline of the last 15 events (jobs, quotes, invoices, deposits) in reverse chronological order.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -195,6 +213,122 @@
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="additional-contacts" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-people text-primary me-2"></i>Additional Contacts
|
||||
</h2>
|
||||
<p>
|
||||
Commercial customers often have more than one person involved in their account — a purchasing
|
||||
manager, a billing contact, or the person who actually drops off and picks up parts. The
|
||||
<strong>Additional Contacts</strong> section on the Customer Details page lets you store all of
|
||||
them in one place so your team always knows who to call.
|
||||
</p>
|
||||
<p>To add a contact, open the Customer Details page and click <strong>Add Contact</strong> in the
|
||||
Additional Contacts card. You can record:</p>
|
||||
<ul class="mb-3">
|
||||
<li><strong>Name</strong> — first and last name.</li>
|
||||
<li><strong>Job Title</strong> — their role at the company (e.g., “Purchasing Manager”).</li>
|
||||
<li><strong>Role</strong> — a category tag: Billing, Operations, Drop-Off, Sales, General, or Other.</li>
|
||||
<li><strong>Email & Phone</strong> — their direct contact details.</li>
|
||||
<li><strong>Notes</strong> — anything else your team should know about this person.</li>
|
||||
</ul>
|
||||
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-0" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
<strong>Notifications always go to the primary contact.</strong> Additional contacts are for
|
||||
staff reference only. All automated emails (job ready for pickup, invoice sent, quote
|
||||
approval links, etc.) and SMS messages are sent to the email address and phone number on the
|
||||
main customer record — not to the contacts listed here. If you need invoices routed to a
|
||||
different address, use the <strong>Billing / Accounting Email</strong> field on the main
|
||||
customer record instead.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="ship-to-address" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-truck text-primary me-2"></i>Ship-To / Pickup Address
|
||||
</h2>
|
||||
<p>
|
||||
Some customers have a different address for pickups or deliveries than their billing address. You
|
||||
can record a separate <strong>Ship-To</strong> address on the Create or Edit form. Leave it blank
|
||||
if the customer picks up from the same address they bill from.
|
||||
</p>
|
||||
<p>
|
||||
When a ship-to address is on file, the Customer Details page splits the Address card into two
|
||||
columns — billing on the left, ship-to on the right — so the difference is immediately visible
|
||||
to anyone looking up the customer.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="lead-source" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-signpost text-primary me-2"></i>Lead Source
|
||||
</h2>
|
||||
<p>
|
||||
The <strong>Lead Source</strong> field lets you record how a customer found your shop. Options
|
||||
include Walk-In, Google Search, Customer Referral, Social Media, Website, Repeat Customer, Trade
|
||||
Show / Event, Flyer / Print Ad, and Other.
|
||||
</p>
|
||||
<p>
|
||||
This field is optional and is shown on the Customer Details page under Business Information. It
|
||||
is useful for understanding which marketing channels are bringing in customers over time.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="preferred-powders" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-droplet-half text-primary me-2"></i>Preferred Powders
|
||||
</h2>
|
||||
<p>
|
||||
The <strong>Preferred Powders</strong> card on the Customer Details page lets you tag inventory
|
||||
items that this customer regularly orders. It is a staff-reference tool — it does not auto-select
|
||||
powders on quotes or jobs, but it gives anyone creating a quote a quick look at what colors this
|
||||
customer has used before.
|
||||
</p>
|
||||
<p>To add a preferred powder:</p>
|
||||
<ol class="mb-3">
|
||||
<li class="mb-1">Open the Customer Details page.</li>
|
||||
<li class="mb-1">In the <strong>Preferred Powders</strong> card, type part of the powder name or SKU into the search box.</li>
|
||||
<li class="mb-1">Select the item from the dropdown and click <strong>Add</strong>.</li>
|
||||
</ol>
|
||||
<p>To remove a preferred powder, click the <strong>×</strong> button next to the item in the list.</p>
|
||||
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
|
||||
<i class="bi bi-info-circle-fill flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
Only items that already exist in your <strong>Inventory</strong> can be added as preferred powders.
|
||||
If a color isn’t appearing in the search, check that it has been added to inventory first.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="outstanding-pickups" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-box-seam text-primary me-2"></i>Outstanding Pickups
|
||||
</h2>
|
||||
<p>
|
||||
When one or more of a customer’s jobs are in <strong>Ready for Pickup</strong> status, a
|
||||
highlighted card appears in the right column of their Customer Details page. This lets your front desk
|
||||
staff immediately see — without opening the Jobs list — whether a customer calling or walking
|
||||
in has finished work waiting for them.
|
||||
</p>
|
||||
<p>The card shows:</p>
|
||||
<ul class="mb-3">
|
||||
<li>The job number (clickable, opens the Job Details page).</li>
|
||||
<li>How many days the job has been waiting in “Ready for Pickup” status.</li>
|
||||
</ul>
|
||||
<p>Color coding helps prioritize follow-up calls:</p>
|
||||
<ul class="mb-3">
|
||||
<li><span class="badge bg-warning text-dark">Amber</span> — waiting 3–6 days.</li>
|
||||
<li><span class="badge bg-danger">Red</span> — waiting 7 or more days.</li>
|
||||
<li>No color — waiting 0–2 days (recently completed).</li>
|
||||
</ul>
|
||||
<p>
|
||||
The card disappears automatically once all jobs for this customer have moved out of
|
||||
“Ready for Pickup” status (e.g., to Delivered).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="deactivating-a-customer" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-person-dash text-primary me-2"></i>Deactivating a Customer
|
||||
@@ -235,6 +369,11 @@
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#customer-details">Customer Details Page</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#credit-limit">Credit Limit</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#tax-exempt">Tax Exempt</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#additional-contacts">Additional Contacts</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#ship-to-address">Ship-To Address</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#lead-source">Lead Source</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#preferred-powders">Preferred Powders</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#outstanding-pickups">Outstanding Pickups</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#deactivating-a-customer">Deactivating a Customer</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -415,7 +415,11 @@
|
||||
An alert banner is shown on the item's Details page prompting you to use Stock Adjustment to add inventory.
|
||||
</li>
|
||||
</ul>
|
||||
<p>Low Stock and Out of Stock items appear in the Inventory Alerts section on the Dashboard and in the Operations Report. Use the <strong>Low Stock</strong> filter on the Inventory list to see only items needing attention.</p>
|
||||
<p>
|
||||
Low Stock and Out of Stock items appear in the Inventory Alerts section on the Dashboard and in the Operations Report.
|
||||
The <strong>Low Stock</strong> stat card at the top of the Inventory page is clickable — click it to instantly
|
||||
filter the list to only items needing attention. Click it again (or clear the filter) to return to the full list.
|
||||
</p>
|
||||
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-0" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
<li class="mb-2">Select the customer and then select the job this invoice is for.</li>
|
||||
<li class="mb-2">Add or adjust line items as needed.</li>
|
||||
<li class="mb-2">Set the invoice date, due date, and any notes.</li>
|
||||
<li class="mb-2">Optionally enter a <strong>Project Name</strong>. When creating from a job, this pre-fills from the job's project name automatically.</li>
|
||||
<li class="mb-2">Click <strong>Save Invoice</strong>.</li>
|
||||
</ol>
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
<li class="mb-2">Choose a <strong>Priority</strong> — Normal is the default; see the Job Priority section below for all levels.</li>
|
||||
<li class="mb-2">Optionally assign a <strong>Worker</strong> from your shop workers list.</li>
|
||||
<li class="mb-2">Enter the customer's <strong>PO Number</strong> if they require one for their own records.</li>
|
||||
<li class="mb-2">Optionally enter a <strong>Project Name</strong> — a short label that groups related jobs (e.g., “Spring Fleet Refresh”). It appears on the job, its invoice, and printed work orders.</li>
|
||||
<li class="mb-2">Add any <strong>Special Instructions</strong> your team needs to know before starting work.</li>
|
||||
<li class="mb-2">Add one or more <strong>Line Items</strong> describing each piece being coated. See the Job Items section below.</li>
|
||||
<li class="mb-2">
|
||||
@@ -553,6 +554,33 @@
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section id="clone-job" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-copy text-primary me-2"></i>Cloning a Job
|
||||
</h2>
|
||||
<p>
|
||||
If you need to create a new job that is identical or very similar to one you have already done,
|
||||
use the <strong>Clone</strong> button on the Job Details page. This saves you from re-entering
|
||||
all the line items and coatings from scratch.
|
||||
</p>
|
||||
<p>Cloning copies the following to a brand-new Pending job:</p>
|
||||
<ul class="mb-3">
|
||||
<li>Customer and all job settings (description, PO number, project name, special instructions, tags, priority, discount, oven settings)</li>
|
||||
<li>All line items with their coatings, colors, prep services, and pricing</li>
|
||||
</ul>
|
||||
<p>The following are <strong>not</strong> copied:</p>
|
||||
<ul class="mb-3">
|
||||
<li>Scheduled date, due date — you set these on the new job</li>
|
||||
<li>Assigned worker</li>
|
||||
<li>Photos, job notes, and time entries</li>
|
||||
<li>Invoice and payment records</li>
|
||||
</ul>
|
||||
<p>
|
||||
After cloning, the new job opens directly so you can review it, adjust dates, and save.
|
||||
A new unique job number (<code>JOB-YYMM-####</code>) is generated automatically.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="shop-display-and-board" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-display text-primary me-2"></i>Shop Display and Priority Board
|
||||
@@ -827,6 +855,7 @@
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#photos-notes">Photos and Notes</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#time-and-rework">Time Entries and Rework</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#job-templates">Job Templates</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#clone-job">Cloning a Job</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#shop-display-and-board">Shop Display & Priority Board</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#part-intake">Part Intake</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#shop-mobile">Shop Mobile</a>
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
</li>
|
||||
<li class="mb-2">Set the <strong>Quote Date</strong> (defaults to today) and the <strong>Expiry Date</strong> (defaults to the system's configured validity period).</li>
|
||||
<li class="mb-2">Add a <strong>Subject</strong> or description to identify the work being quoted.</li>
|
||||
<li class="mb-2">Optionally enter a <strong>Project Name</strong> — a short label that groups related work (e.g., “Fleet Refresh Q2”). It appears on the quote PDF and carries over to the job and invoice when converted.</li>
|
||||
<li class="mb-2">Add one or more <strong>Line Items</strong> — see the Quote Items section below for item types.</li>
|
||||
<li class="mb-2">Add any <strong>Notes</strong> for the customer (these appear on the printed quote).</li>
|
||||
<li class="mb-2">Add any internal <strong>Notes</strong> that are for your team only.</li>
|
||||
|
||||
@@ -44,19 +44,30 @@
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<p class="text-muted mb-1" style="font-size: 0.875rem;">Low Stock Items</p>
|
||||
<h3 class="mb-0 fw-bold @(lowStockCount > 0 ? "text-danger" : "")">@lowStockCount</h3>
|
||||
</div>
|
||||
<div class="rounded-circle p-3" style="background: #fee2e2;">
|
||||
<i class="bi bi-exclamation-triangle text-danger" style="font-size: 1.5rem;"></i>
|
||||
<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="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<div class="rounded-circle p-3" style="background: #fee2e2;">
|
||||
<i class="bi bi-exclamation-triangle text-danger" style="font-size: 1.5rem;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
@@ -102,11 +113,13 @@
|
||||
<div class="stat-value">@Model.TotalCount</div>
|
||||
<div class="stat-label">Total</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-value @(lowStockCount > 0 ? "text-danger" : "")">@lowStockCount</div>
|
||||
<div class="stat-label">Low Stock</div>
|
||||
</div>
|
||||
</a>
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon"><i class="bi bi-check-circle text-success"></i></div>
|
||||
<div class="stat-value">@activeCount</div>
|
||||
|
||||
@@ -353,6 +353,11 @@
|
||||
<label class="form-label fw-semibold">Powder Item</label>
|
||||
<p id="euItemName" class="form-control-plaintext text-muted"></p>
|
||||
</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">
|
||||
<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">
|
||||
|
||||
@@ -170,6 +170,12 @@
|
||||
<input asp-for="CustomerPO" class="form-control" placeholder="Optional" />
|
||||
</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 — prints on invoice" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-12">
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
|
||||
@@ -193,6 +193,13 @@
|
||||
<p class="mb-0">@Model.CustomerPO</p>
|
||||
</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))
|
||||
{
|
||||
<div class="col-md-6">
|
||||
|
||||
@@ -62,6 +62,12 @@
|
||||
<input asp-for="CustomerPO" class="form-control" placeholder="Optional" />
|
||||
</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 — prints on invoice" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-12">
|
||||
<label asp-for="Terms" class="form-label fw-semibold">Payment Terms</label>
|
||||
|
||||
@@ -124,6 +124,10 @@
|
||||
</div>
|
||||
<input asp-for="CustomerPO" class="form-control" placeholder="Enter PO number" />
|
||||
</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…" />
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<label asp-for="SpecialInstructions" class="form-label mb-0">Special Instructions</label>
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
title="Save this job as a reusable template">
|
||||
<i class="bi bi-layout-text-window-reverse me-2"></i>Save as Template
|
||||
</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'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">
|
||||
<i class="bi bi-pencil me-2"></i>Edit
|
||||
</a>
|
||||
@@ -172,6 +176,13 @@
|
||||
<label class="text-muted small mb-1">Customer PO</label>
|
||||
<p class="mb-0">@(Model.CustomerPO ?? "Not provided")</p>
|
||||
</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">
|
||||
<label class="text-muted small mb-1">Description</label>
|
||||
<p class="mb-0">@Model.Description</p>
|
||||
@@ -1158,21 +1169,24 @@
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Entry Method</label>
|
||||
<div class="d-flex gap-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="lmEntryMethod" id="lmMethodUsed" value="used" checked onchange="lmUpdateQuantityLabel()">
|
||||
<label class="form-check-label" for="lmMethodUsed">Amount Used</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="lmEntryMethod" id="lmMethodRemaining" value="remaining" onchange="lmUpdateQuantityLabel()">
|
||||
<label class="form-check-label" for="lmMethodRemaining">Amount Remaining</label>
|
||||
</div>
|
||||
<div class="btn-group w-100" role="group">
|
||||
<button type="button" id="lmBtnUsed" class="btn btn-primary"
|
||||
onclick="lmSetMethod('used')">
|
||||
<i class="bi bi-droplet me-1"></i>Amount Used
|
||||
</button>
|
||||
<button type="button" id="lmBtnRemaining" class="btn btn-outline-primary"
|
||||
onclick="lmSetMethod('remaining')">
|
||||
<i class="bi bi-droplet-half me-1"></i>Amount Remaining
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
<span id="lmMethodHint">Enter how much powder you took out of the bag.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label id="lmQtyLabel" class="form-label fw-semibold">Quantity Used <span class="text-danger">*</span></label>
|
||||
<input type="number" id="lmQuantity" class="form-control" min="0" step="0.01" placeholder="0.00">
|
||||
<div id="lmComputedUsed" class="form-text text-muted d-none"></div>
|
||||
<div id="lmComputedUsed" class="form-text fw-semibold d-none"></div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Reason</label>
|
||||
@@ -3311,7 +3325,7 @@
|
||||
const inventoryItems = @Html.Raw(ViewBag.InventoryItemsForModal ?? "[]");
|
||||
const jobPowderIds = @Html.Raw(ViewBag.JobPowderIds ?? "[]");
|
||||
const jobId = @Model.Id;
|
||||
const logUrl = '@Url.Action("LogMaterial", "Jobs")';
|
||||
const logUrl = '@Url.Action("LogMaterial", "Inventory")';
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
window.__logMaterial = { inventoryItems, jobPowderIds, jobId, logUrl, token };
|
||||
})();
|
||||
|
||||
@@ -101,6 +101,10 @@
|
||||
<label asp-for="CustomerPO" class="form-label">Customer PO</label>
|
||||
<input asp-for="CustomerPO" class="form-control" placeholder="Enter PO number" />
|
||||
</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…" />
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<label asp-for="SpecialInstructions" class="form-label">Special Instructions</label>
|
||||
<textarea asp-for="SpecialInstructions" class="form-control" rows="3" placeholder="Any special instructions"></textarea>
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
<a href="@Url.Action("Index", new { statusGroup = "overdue" })" class="pcl-pill @(_activeGroup == "overdue" ? "active" : "")">
|
||||
Overdue <span class="pcl-pill-count">@_overdue</span>
|
||||
</a>
|
||||
<a href="@Url.Action("Index", new { searchTerm = "ReadyForPickup" })" class="pcl-pill @(_activeSearch == "ReadyForPickup" ? "active" : "")">
|
||||
<a href="@Url.Action("Index", new { statusGroup = "ready" })" class="pcl-pill @(_activeGroup == "ready" ? "active" : "")">
|
||||
Ready <span class="pcl-pill-count">@_ready</span>
|
||||
</a>
|
||||
<a href="@Url.Action("Index", new { statusGroup = "completed" })" class="pcl-pill @(_activeGroup == "completed" ? "active" : "")">
|
||||
@@ -162,10 +162,20 @@
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
|
||||
<h5 class="mt-3 text-muted">No jobs found</h5>
|
||||
<p class="text-muted mb-4">Get started by creating your first job</p>
|
||||
<a asp-action="Create" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-2"></i>Create Your First Job
|
||||
</a>
|
||||
@if (_allCount > 0)
|
||||
{
|
||||
<p class="text-muted mb-4">No jobs match your current filter.</p>
|
||||
<a href="@Url.Action("Index", new { statusGroup = "active" })" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-circle me-2"></i>Clear Filters
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted mb-4">Get started by creating your first job.</p>
|
||||
<a asp-action="Create" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-2"></i>Create Your First Job
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
@@ -191,7 +201,14 @@
|
||||
var isHot = job.DueDate.HasValue && job.DueDate.Value < DateTime.Now
|
||||
&& job.StatusCode != "COMPLETED" && job.StatusCode != "READYFORPICKUP"
|
||||
&& 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" : "")">
|
||||
<div>
|
||||
<div class="mono fw-500">
|
||||
@@ -321,10 +338,20 @@
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
|
||||
<h5 class="mt-3 text-muted">No jobs found</h5>
|
||||
<p class="text-muted mb-4">Get started by creating your first job</p>
|
||||
<a asp-action="Create" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-2"></i>Create Your First Job
|
||||
</a>
|
||||
@if (_allCount > 0)
|
||||
{
|
||||
<p class="text-muted mb-4">No jobs match your current filter.</p>
|
||||
<a href="@Url.Action("Index", new { statusGroup = "active" })" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-circle me-2"></i>Clear Filters
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted mb-4">Get started by creating your first job.</p>
|
||||
<a asp-action="Create" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-2"></i>Create Your First Job
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
@@ -629,6 +656,10 @@
|
||||
loadJobStatuses();
|
||||
loadJobPriorities();
|
||||
|
||||
// Row tooltips (description + PO)
|
||||
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el =>
|
||||
new bootstrap.Tooltip(el, { trigger: 'hover' }));
|
||||
|
||||
// / key focuses search input
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === '/' && document.activeElement.tagName !== 'INPUT' && document.activeElement.tagName !== 'TEXTAREA') {
|
||||
|
||||
@@ -357,6 +357,13 @@
|
||||
<div class="info-value">@Model.CustomerPO</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 class="col-6">
|
||||
<div class="section-title">
|
||||
|
||||
@@ -187,6 +187,12 @@
|
||||
<input asp-for="CustomerPO" class="form-control" />
|
||||
</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…" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
<div class="col-md-6">
|
||||
<label asp-for="Notes" class="form-label"></label>
|
||||
|
||||
@@ -183,6 +183,10 @@
|
||||
{
|
||||
<p><strong>Customer PO:</strong> @Model.CustomerPO</p>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Model.ProjectName))
|
||||
{
|
||||
<p><strong>Project:</strong> @Model.ProjectName</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(Model.Description))
|
||||
|
||||
@@ -150,6 +150,12 @@
|
||||
<input asp-for="CustomerPO" class="form-control" />
|
||||
</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…" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
<div class="col-md-6">
|
||||
<label asp-for="Notes" class="form-label"></label>
|
||||
|
||||
@@ -294,9 +294,9 @@
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-file-text" style="font-size: 4rem; color: #ccc;"></i>
|
||||
<p class="text-muted mt-3">No quotes found.</p>
|
||||
@if (!string.IsNullOrEmpty(searchTerm) || statusFilter.HasValue)
|
||||
@if (!string.IsNullOrEmpty(searchTerm) || statusFilter.HasValue || !string.IsNullOrEmpty(statusCode) || !string.IsNullOrEmpty(ViewBag.TagFilter as string))
|
||||
{
|
||||
<p class="text-muted">Try adjusting your filters or <a asp-action="Index" asp-controller="Quotes">view all quotes</a>.</p>
|
||||
<p class="text-muted">No quotes match your current filter. <a asp-action="Index" asp-controller="Quotes">View all quotes</a>.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
(function () {
|
||||
let cfFields = [];
|
||||
let cfEditing = false;
|
||||
let cfFormDirty = false;
|
||||
|
||||
// ── Load & Render ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -221,9 +222,11 @@
|
||||
document.getElementById('cfDiagramImg').src = `/CompanySettings/TemplateDiagram?templateId=${t.id}`;
|
||||
document.getElementById('cfDiagramPreview').style.display = '';
|
||||
}
|
||||
cfFormDirty = false;
|
||||
}
|
||||
|
||||
function cfResetForm() {
|
||||
cfFormDirty = false;
|
||||
document.getElementById('cfId').value = '0';
|
||||
document.getElementById('cfName').value = '';
|
||||
document.getElementById('cfDescription').value = '';
|
||||
@@ -528,6 +531,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
cfFormDirty = false;
|
||||
bootstrap.Modal.getInstance(document.getElementById('cfModal'))?.hide();
|
||||
cfLoadTemplates();
|
||||
} catch (e) {
|
||||
@@ -862,4 +866,103 @@
|
||||
cfWtStep = i;
|
||||
cfRenderWtStep();
|
||||
};
|
||||
|
||||
// ── Import ────────────────────────────────────────────────────────────────
|
||||
|
||||
window.cfShowImport = function () {
|
||||
document.getElementById('cfImportFile').value = '';
|
||||
document.getElementById('cfImportResults').classList.add('d-none');
|
||||
document.getElementById('cfImportSummary').innerHTML = '';
|
||||
const btn = document.getElementById('cfImportBtn');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-upload me-1"></i>Import';
|
||||
new bootstrap.Modal(document.getElementById('cfImportModal')).show();
|
||||
};
|
||||
|
||||
window.cfSubmitImport = async function () {
|
||||
const fileInput = document.getElementById('cfImportFile');
|
||||
if (!fileInput.files.length) {
|
||||
showCfError('Please select a .json export file first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('cfImportBtn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Importing…';
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', fileInput.files[0]);
|
||||
form.append('__RequestVerificationToken', getAntiForgeryToken());
|
||||
|
||||
try {
|
||||
const res = await fetch('/CompanySettings/ImportCustomItemTemplates', { method: 'POST', body: form });
|
||||
const data = await res.json();
|
||||
|
||||
const resultsEl = document.getElementById('cfImportResults');
|
||||
const summaryEl = document.getElementById('cfImportSummary');
|
||||
resultsEl.classList.remove('d-none');
|
||||
|
||||
if (!data.success) {
|
||||
summaryEl.innerHTML = `<div class="alert alert-danger alert-permanent mb-0"><i class="bi bi-x-circle me-2"></i>${escHtml(data.message)}</div>`;
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-upload me-1"></i>Import';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
|
||||
if (data.imported > 0)
|
||||
html += `<div class="alert alert-success alert-permanent mb-2"><i class="bi bi-check-circle me-2"></i><strong>${data.imported}</strong> template${data.imported !== 1 ? 's' : ''} imported successfully.</div>`;
|
||||
|
||||
if (data.skipped > 0) {
|
||||
const names = (data.skippedNames || []).map(n => `<li>${escHtml(n)}</li>`).join('');
|
||||
html += `<div class="alert alert-warning alert-permanent mb-2">
|
||||
<i class="bi bi-skip-forward me-2"></i><strong>${data.skipped}</strong> skipped — name already exists:
|
||||
<ul class="mb-0 mt-1 small">${names}</ul>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (data.errors && data.errors.length) {
|
||||
const items = data.errors.map(e => `<li>${escHtml(e)}</li>`).join('');
|
||||
html += `<div class="alert alert-danger alert-permanent mb-2">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i><strong>${data.errors.length}</strong> error${data.errors.length !== 1 ? 's' : ''}:
|
||||
<ul class="mb-0 mt-1 small">${items}</ul>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (data.imported === 0 && data.skipped === 0 && (!data.errors || !data.errors.length))
|
||||
html = '<div class="alert alert-info alert-permanent mb-0"><i class="bi bi-info-circle me-2"></i>The file contained no templates to import.</div>';
|
||||
|
||||
summaryEl.innerHTML = html;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="bi bi-check me-1"></i>Done';
|
||||
|
||||
if (data.imported > 0) cfLoadTemplates();
|
||||
} catch (e) {
|
||||
showCfError('Import request failed: ' + e.message);
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-upload me-1"></i>Import';
|
||||
}
|
||||
};
|
||||
|
||||
// ── Unsaved-changes guard ─────────────────────────────────────────────────
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const modal = document.getElementById('cfModal');
|
||||
if (!modal) return;
|
||||
|
||||
// Any user interaction inside the modal marks the form dirty
|
||||
modal.addEventListener('input', function () { cfFormDirty = true; });
|
||||
modal.addEventListener('change', function () { cfFormDirty = true; });
|
||||
|
||||
// Intercept backdrop click, ESC, and the X button when there is unsaved work
|
||||
modal.addEventListener('hide.bs.modal', function (e) {
|
||||
if (!cfFormDirty) return;
|
||||
e.preventDefault();
|
||||
if (confirm('You have unsaved changes. Close anyway and lose your work?')) {
|
||||
cfFormDirty = false;
|
||||
bootstrap.Modal.getInstance(modal)?.hide();
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -1094,30 +1094,31 @@
|
||||
3: 'Powder Coat'
|
||||
};
|
||||
|
||||
// Nozzle multipliers matching ShopCapabilityCalculator
|
||||
const blastNozzleMultipliers = [0, 0, 0.35, 0.55, 0.75, 1.00, 1.30, 1.65, 2.00];
|
||||
const blastSetupModalTypeMultipliers = { 0: 0.55, 1: 0.70, 2: 1.00, 3: 0.45 };
|
||||
const blastSubstrateMultipliers = { 0: 1.00, 1: 0.80, 2: 1.40, 3: 0.90 };
|
||||
// No client-side blast-rate formula here — ShopCapabilityCalculator.cs is the single
|
||||
// source of truth. The table uses derivedRate from the server response; the modal
|
||||
// live-preview calls /CompanySettings/DeriveBlastRate instead.
|
||||
|
||||
function baseByCfm(cfm) {
|
||||
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;
|
||||
}
|
||||
let _deriveRateTimer = null;
|
||||
|
||||
function deriveBlastRate(cfm, nozzle, setupType, substrate, override) {
|
||||
if (override && parseFloat(override) > 0) return parseFloat(override);
|
||||
const base = baseByCfm(parseFloat(cfm) || 0);
|
||||
if (base === 0) return 0;
|
||||
const nm = blastNozzleMultipliers[parseInt(nozzle)] || 1.00;
|
||||
const sm = blastSetupModalTypeMultipliers[parseInt(setupType)] || 1.00;
|
||||
const bm = blastSubstrateMultipliers[parseInt(substrate)] || 1.00;
|
||||
return Math.round(base * nm * sm * bm * 10) / 10;
|
||||
function updateBlastSetupDerivedRate() {
|
||||
clearTimeout(_deriveRateTimer);
|
||||
_deriveRateTimer = setTimeout(function () {
|
||||
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 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 () {
|
||||
@@ -1150,7 +1151,7 @@
|
||||
window.blastSetups.forEach(function (setup) {
|
||||
const rate = setup.blastRateSqFtPerHourOverride > 0
|
||||
? 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
|
||||
? ' <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) {
|
||||
const modal = new bootstrap.Modal(document.getElementById('blastSetupModal'));
|
||||
const form = document.getElementById('blastSetupForm');
|
||||
|
||||
@@ -38,6 +38,278 @@ 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 = ''; dropdown.style.display = 'none'; }
|
||||
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('');
|
||||
dropdown.style.display = 'block';
|
||||
} 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 = ''; dropdown.style.display = 'none'; }
|
||||
}
|
||||
|
||||
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}¬es=${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">×</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.');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Customer Contacts ──────────────────────────────────────────────────────
|
||||
|
||||
function openAddContactModal() {
|
||||
document.getElementById('contactId').value = '0';
|
||||
document.getElementById('contactModalTitle').textContent = 'Add Contact';
|
||||
document.getElementById('contactFirstName').value = '';
|
||||
document.getElementById('contactLastName').value = '';
|
||||
document.getElementById('contactTitle').value = '';
|
||||
document.getElementById('contactRole').value = '';
|
||||
document.getElementById('contactEmail').value = '';
|
||||
document.getElementById('contactPhone').value = '';
|
||||
document.getElementById('contactMobilePhone').value = '';
|
||||
document.getElementById('contactNotes').value = '';
|
||||
document.getElementById('contactModalError').classList.add('d-none');
|
||||
}
|
||||
|
||||
async function editContact(customerId, contactId) {
|
||||
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
try {
|
||||
const res = await fetch(`/Customers/GetContact/${customerId}?contactId=${contactId}`);
|
||||
const data = await res.json();
|
||||
if (!data.success) { toastr.error('Could not load contact.'); return; }
|
||||
|
||||
const c = data.contact;
|
||||
document.getElementById('contactId').value = c.id;
|
||||
document.getElementById('contactModalTitle').textContent = 'Edit Contact';
|
||||
document.getElementById('contactFirstName').value = c.firstName ?? '';
|
||||
document.getElementById('contactLastName').value = c.lastName ?? '';
|
||||
document.getElementById('contactTitle').value = c.title ?? '';
|
||||
document.getElementById('contactRole').value = c.contactRole ?? '';
|
||||
document.getElementById('contactEmail').value = c.email ?? '';
|
||||
document.getElementById('contactPhone').value = c.phone ?? '';
|
||||
document.getElementById('contactMobilePhone').value = c.mobilePhone ?? '';
|
||||
document.getElementById('contactNotes').value = c.notes ?? '';
|
||||
document.getElementById('contactModalError').classList.add('d-none');
|
||||
|
||||
new bootstrap.Modal(document.getElementById('contactModal')).show();
|
||||
} catch {
|
||||
toastr.error('An error occurred loading the contact.');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveContact(customerId) {
|
||||
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
const contactId = parseInt(document.getElementById('contactId').value ?? '0', 10);
|
||||
const firstName = document.getElementById('contactFirstName').value.trim();
|
||||
|
||||
if (!firstName) {
|
||||
const err = document.getElementById('contactModalError');
|
||||
err.textContent = 'First name is required.';
|
||||
err.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
FirstName: firstName,
|
||||
LastName: document.getElementById('contactLastName').value.trim(),
|
||||
Title: document.getElementById('contactTitle').value.trim(),
|
||||
ContactRole: document.getElementById('contactRole').value,
|
||||
Email: document.getElementById('contactEmail').value.trim(),
|
||||
Phone: document.getElementById('contactPhone').value.trim(),
|
||||
MobilePhone: document.getElementById('contactMobilePhone').value.trim(),
|
||||
Notes: document.getElementById('contactNotes').value.trim(),
|
||||
});
|
||||
|
||||
const isEdit = contactId > 0;
|
||||
if (isEdit) { params.append('Id', contactId); params.append('CustomerId', customerId); }
|
||||
|
||||
const url = isEdit ? `/Customers/UpdateContact/${customerId}` : `/Customers/AddContact/${customerId}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tok },
|
||||
body: params.toString()
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('contactModal'))?.hide();
|
||||
const tbody = document.getElementById('contacts-table-body');
|
||||
const placeholder = document.getElementById('no-contacts-placeholder');
|
||||
if (placeholder) placeholder.remove();
|
||||
|
||||
if (isEdit) {
|
||||
const existing = tbody.querySelector(`tr[data-contact-id="${contactId}"]`);
|
||||
if (existing) existing.outerHTML = data.rowHtml;
|
||||
else tbody.insertAdjacentHTML('beforeend', data.rowHtml);
|
||||
} else {
|
||||
tbody.insertAdjacentHTML('beforeend', data.rowHtml);
|
||||
}
|
||||
toastr.success(isEdit ? 'Contact updated.' : 'Contact added.');
|
||||
} else {
|
||||
const err = document.getElementById('contactModalError');
|
||||
err.textContent = data.message || 'An error occurred.';
|
||||
err.classList.remove('d-none');
|
||||
}
|
||||
} catch {
|
||||
toastr.error('An error occurred. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteContact(customerId, contactId) {
|
||||
if (!confirm('Delete this contact?')) return;
|
||||
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
try {
|
||||
const res = await fetch(`/Customers/DeleteContact/${customerId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tok },
|
||||
body: `contactId=${contactId}`
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
document.querySelector(`tr[data-contact-id="${contactId}"]`)?.remove();
|
||||
const tbody = document.getElementById('contacts-table-body');
|
||||
if (tbody && tbody.querySelectorAll('tr[data-contact-id]').length === 0)
|
||||
tbody.insertAdjacentHTML('afterbegin', '<tr id="no-contacts-placeholder"><td colspan="4" class="text-muted small px-3 py-2">No additional contacts.</td></tr>');
|
||||
toastr.success('Contact deleted.');
|
||||
} else {
|
||||
toastr.error(data.message || 'Could not delete contact.');
|
||||
}
|
||||
} catch {
|
||||
toastr.error('An error occurred. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
window.updateCustomerSmsStatus = function () {
|
||||
const section = document.getElementById('sms-status-section');
|
||||
if (!section) return;
|
||||
|
||||
@@ -18,6 +18,7 @@ async function openUsageEdit(transactionId) {
|
||||
|
||||
document.getElementById('euTxnId').value = data.transactionId;
|
||||
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('euNotes').value = data.notes || '';
|
||||
|
||||
@@ -54,6 +55,7 @@ document.getElementById('euSaveBtn').addEventListener('click', async () => {
|
||||
const token = form.querySelector('input[name="__RequestVerificationToken"]')?.value;
|
||||
const params = new URLSearchParams({
|
||||
id: document.getElementById('euTxnId').value,
|
||||
quantity: document.getElementById('euQuantity').value,
|
||||
jobId: document.getElementById('euJobId').value,
|
||||
notes: document.getElementById('euNotes').value,
|
||||
transactionDate: document.getElementById('euDate').value,
|
||||
|
||||
@@ -691,7 +691,7 @@ function renderSalesFields() {
|
||||
</button>
|
||||
</div>
|
||||
<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 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;"
|
||||
data-id="${m.id}" data-name="${escHtml(m.name)}" data-price="${m.price}" data-sku="${escHtml(m.sku || '')}"
|
||||
onmousedown="event.preventDefault();wzMerchComboSelect(this)"
|
||||
onmouseenter="this.style.background='#f0f4ff'"
|
||||
onmouseenter="this.style.background='var(--bs-secondary-bg)'"
|
||||
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>
|
||||
</div>`
|
||||
@@ -1510,8 +1510,11 @@ async function aiAnalyze() {
|
||||
document.getElementById('ai_resultsSection')?.classList.add('d-none');
|
||||
document.getElementById('ai_errorAlert')?.classList.add('d-none');
|
||||
|
||||
const blastSetupIdEl = document.getElementById('ai_blastSetupId');
|
||||
const blastSetupId = blastSetupIdEl ? (parseInt(blastSetupIdEl.value) || null) : null;
|
||||
const blastSetupIdEl = document.getElementById('ai_blastSetupId');
|
||||
const _defaultSetup = blastSetupData.find(s => s.isDefault) || blastSetupData[0];
|
||||
const blastSetupId = blastSetupIdEl
|
||||
? (parseInt(blastSetupIdEl.value) || null)
|
||||
: (_defaultSetup ? _defaultSetup.id : null);
|
||||
|
||||
const payload = {
|
||||
photoTempIds: wz.ai.tempIds,
|
||||
@@ -1590,8 +1593,11 @@ async function aiSendFollowup() {
|
||||
const weightLbs = isNaN(weightLbsRaw) || weightLbsRaw <= 0 ? null : weightLbsRaw;
|
||||
wz.data.quantity = qty; // persist before renderStep re-renders
|
||||
|
||||
const blastSetupIdEl2 = document.getElementById('ai_blastSetupId');
|
||||
const blastSetupId2 = blastSetupIdEl2 ? (parseInt(blastSetupIdEl2.value) || null) : null;
|
||||
const blastSetupIdEl2 = document.getElementById('ai_blastSetupId');
|
||||
const _defaultSetup2 = blastSetupData.find(s => s.isDefault) || blastSetupData[0];
|
||||
const blastSetupId2 = blastSetupIdEl2
|
||||
? (parseInt(blastSetupIdEl2.value) || null)
|
||||
: (_defaultSetup2 ? _defaultSetup2.id : null);
|
||||
|
||||
const payload = {
|
||||
photoTempIds: wz.ai.tempIds,
|
||||
@@ -1909,7 +1915,7 @@ function buildCoatRowHtml(i, coat) {
|
||||
<input type="hidden" id="coat_inventoryItemId_${i}">
|
||||
<div id="coat_powder_dropdown_${i}"
|
||||
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>
|
||||
@@ -1968,7 +1974,7 @@ function buildCoatRowHtml(i, coat) {
|
||||
</div>
|
||||
<div id="coat_catalog_results_${i}"
|
||||
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 class="col-sm-6">
|
||||
@@ -2166,7 +2172,7 @@ function powderComboRender(i, query) {
|
||||
data-val="${escHtml(String(p.value))}"
|
||||
data-txt="${escHtml(p.text)}"
|
||||
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=''">
|
||||
${escHtml(displayText)}${badge}
|
||||
</div>`;
|
||||
@@ -2214,12 +2220,12 @@ function powderComboKey(event, i) {
|
||||
event.preventDefault();
|
||||
idx = Math.min(idx + 1, items.length - 1);
|
||||
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') {
|
||||
event.preventDefault();
|
||||
idx = Math.max(idx - 1, 0);
|
||||
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') {
|
||||
event.preventDefault();
|
||||
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>` : '';
|
||||
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, '"')})"
|
||||
onmouseenter="this.style.background='#f0f4ff'"
|
||||
onmouseenter="this.style.background='var(--bs-secondary-bg)'"
|
||||
onmouseleave="this.style.background=''">
|
||||
<strong>${escHtml(r.colorName)}</strong> — ${escHtml(r.vendorName)}
|
||||
<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;"
|
||||
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=''">
|
||||
<i class="bi bi-truck text-warning me-1"></i>
|
||||
<strong>${escHtml(r.colorName)}</strong> — ${escHtml(r.vendorName)} ${escHtml(r.sku || '')}
|
||||
|
||||
@@ -6,9 +6,64 @@
|
||||
let _items = [];
|
||||
let _jobPowderIds = new Set();
|
||||
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 ────────────────────────────────────────────────────────
|
||||
let _selectedItemId = 0;
|
||||
|
||||
function lmComboInput() {
|
||||
const q = document.getElementById('lmItemSearch')?.value?.toLowerCase() || '';
|
||||
@@ -16,7 +71,7 @@
|
||||
lmComboShow();
|
||||
_selectedItemId = 0;
|
||||
document.getElementById('lmItemBalance').classList.add('d-none');
|
||||
lmOnQtyInput();
|
||||
lmUpdatePreview();
|
||||
}
|
||||
|
||||
function lmComboOpen() {
|
||||
@@ -111,7 +166,7 @@
|
||||
const balDiv = document.getElementById('lmItemBalance');
|
||||
balDiv.textContent = 'Current stock: ' + qty.toFixed(2) + (uom ? ' ' + uom : '');
|
||||
balDiv.classList.remove('d-none');
|
||||
lmOnQtyInput();
|
||||
lmUpdatePreview();
|
||||
};
|
||||
|
||||
window.lmComboInput = lmComboInput;
|
||||
@@ -152,39 +207,14 @@
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ── Quantity / label logic ────────────────────────────────────────────────
|
||||
|
||||
function lmOnQtyInput() {
|
||||
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
|
||||
if (method !== 'remaining') {
|
||||
document.getElementById('lmComputedUsed').classList.add('d-none');
|
||||
return;
|
||||
}
|
||||
if (!_selectedItemId) {
|
||||
document.getElementById('lmComputedUsed').classList.add('d-none');
|
||||
return;
|
||||
}
|
||||
const item = _items.find(it => it.id === _selectedItemId);
|
||||
const onHand = item ? (parseFloat(item.quantityOnHand) || 0) : 0;
|
||||
const remaining = parseFloat(document.getElementById('lmQuantity').value) || 0;
|
||||
const used = onHand - remaining;
|
||||
const computedDiv = document.getElementById('lmComputedUsed');
|
||||
computedDiv.textContent = 'Usage = ' + onHand.toFixed(2) + ' − ' + remaining.toFixed(2) + ' = ' + used.toFixed(2) + (item?.unitOfMeasure ? ' ' + item.unitOfMeasure : '');
|
||||
computedDiv.classList.remove('d-none');
|
||||
}
|
||||
|
||||
window.lmUpdateQuantityLabel = function () {
|
||||
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
|
||||
document.getElementById('lmQtyLabel').innerHTML =
|
||||
(method === 'remaining' ? 'Quantity Remaining' : 'Quantity Used') +
|
||||
' <span class="text-danger">*</span>';
|
||||
lmOnQtyInput();
|
||||
};
|
||||
// ── Kept for backward-compat with any inline onchange handlers that may exist ─
|
||||
window.lmUpdateQuantityLabel = function () { lmUpdatePreview(); };
|
||||
|
||||
// ── Modal open / save ─────────────────────────────────────────────────────
|
||||
|
||||
window.openLogMaterialModal = function () {
|
||||
_selectedItemId = 0;
|
||||
_entryMethod = 'used';
|
||||
document.getElementById('lmItemSearch').value = '';
|
||||
document.getElementById('lmItemBalance').classList.add('d-none');
|
||||
document.getElementById('lmQuantity').value = '';
|
||||
@@ -193,8 +223,7 @@
|
||||
document.getElementById('lmNotes').value = '';
|
||||
document.getElementById('lmAlert').classList.add('d-none');
|
||||
document.getElementById('lmSaveBtn').disabled = false;
|
||||
document.getElementById('lmMethodUsed').checked = true;
|
||||
window.lmUpdateQuantityLabel();
|
||||
lmSetMethod('used');
|
||||
lmComboClose();
|
||||
if (_modal) _modal.show();
|
||||
};
|
||||
@@ -214,14 +243,14 @@
|
||||
const qtyInput = parseFloat(document.getElementById('lmQuantity').value) || 0;
|
||||
if (qtyInput <= 0) { showError('Please enter a quantity greater than zero.'); return; }
|
||||
|
||||
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
|
||||
const item = _items.find(it => it.id === _selectedItemId);
|
||||
const onHand = item ? (parseFloat(item.quantityOnHand) || 0) : 0;
|
||||
|
||||
let quantityUsed = qtyInput;
|
||||
if (method === 'remaining') {
|
||||
const item = _items.find(it => it.id === _selectedItemId);
|
||||
const onHand = item ? (parseFloat(item.quantityOnHand) || 0) : 0;
|
||||
if (_entryMethod === 'remaining') {
|
||||
quantityUsed = onHand - qtyInput;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -269,9 +298,8 @@
|
||||
_jobPowderIds = new Set(cfg.jobPowderIds || []);
|
||||
_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) {
|
||||
if (!e.target.closest('#lmItemSearch') &&
|
||||
!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]
|
||||
public void GetBlastRateSqFtPerHour_WithNoCompressorCfm_ReturnsZero()
|
||||
public void GetBlastRateSqFtPerHour_PressurePot_Nozzle6_Paint()
|
||||
{
|
||||
// PressurePotRateByNozzle(6) = 245 * SubstrateMultiplier(Paint) 1.0 = 245
|
||||
var costs = new CompanyOperatingCosts
|
||||
{
|
||||
CompressorCfm = 0m
|
||||
};
|
||||
|
||||
var result = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(costs);
|
||||
|
||||
Assert.Equal(0m, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBlastRateSqFtPerHour_DerivesRateFromEquipmentInputs()
|
||||
{
|
||||
var costs = new CompanyOperatingCosts
|
||||
{
|
||||
CompressorCfm = 150m,
|
||||
CompressorCfm = 200m,
|
||||
BlastNozzleSize = 6,
|
||||
BlastSetupType = BlastSetupType.PressurePot,
|
||||
PrimaryBlastSubstrate = BlastSubstrateType.Paint
|
||||
@@ -49,16 +37,17 @@ public class ShopCapabilityCalculatorTests
|
||||
|
||||
var result = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(costs);
|
||||
|
||||
Assert.Equal(58.5m, result);
|
||||
Assert.Equal(245m, result);
|
||||
}
|
||||
|
||||
[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
|
||||
{
|
||||
Name = "Main Cabinet",
|
||||
CompressorCfm = 7m,
|
||||
CompressorCfm = 42m,
|
||||
BlastNozzleSize = 4,
|
||||
SetupType = BlastSetupType.SiphonCabinet,
|
||||
PrimarySubstrate = BlastSubstrateType.Mixed
|
||||
@@ -66,7 +55,39 @@ public class ShopCapabilityCalculatorTests
|
||||
|
||||
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]
|
||||
@@ -86,10 +107,10 @@ public class ShopCapabilityCalculatorTests
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ShopCapabilityTier.Garage, BlastSetupType.SiphonCabinet, 7, 4, BlastSubstrateType.Mixed)]
|
||||
[InlineData(ShopCapabilityTier.Small, BlastSetupType.PressurePot, 40, 5, BlastSubstrateType.Mixed)]
|
||||
[InlineData(ShopCapabilityTier.Medium, BlastSetupType.PressurePot, 80, 5, BlastSubstrateType.Mixed)]
|
||||
[InlineData(ShopCapabilityTier.Large, BlastSetupType.PressurePot, 150, 6, BlastSubstrateType.Mixed)]
|
||||
[InlineData(ShopCapabilityTier.Garage, BlastSetupType.SiphonCabinet, 7, 3, BlastSubstrateType.Mixed)]
|
||||
[InlineData(ShopCapabilityTier.Small, BlastSetupType.PressurePot, 49, 3, BlastSubstrateType.Mixed)]
|
||||
[InlineData(ShopCapabilityTier.Medium, BlastSetupType.PressurePot, 90, 4, BlastSubstrateType.Mixed)]
|
||||
[InlineData(ShopCapabilityTier.Large, BlastSetupType.PressurePot, 150, 5, BlastSubstrateType.Mixed)]
|
||||
public void TierDefaults_ReturnExpectedPresetValues(
|
||||
ShopCapabilityTier tier,
|
||||
BlastSetupType expectedSetup,
|
||||
|
||||
Reference in New Issue
Block a user