Compare commits
16 Commits
cd4c233b60
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 711cd01cd3 | |||
| 7cbae31916 | |||
| 9367e358d9 | |||
| 9f1460c9c0 | |||
| 94e536178c | |||
| 456d054229 | |||
| f38a1e3273 | |||
| 03b425a12f | |||
| 8453449833 | |||
| ad986561c9 | |||
| 0d5553f3b2 | |||
| 87bbf158a4 | |||
| f453a95f28 | |||
| d9e98a55d2 | |||
| 99deca3b62 | |||
| 23e64829bb |
@@ -0,0 +1,35 @@
|
||||
namespace PowderCoating.Application.DTOs.Customer;
|
||||
|
||||
/// <summary>A single entry in the customer activity timeline feed on the Details page.</summary>
|
||||
public class CustomerTimelineEventDto
|
||||
{
|
||||
public DateTime Date { get; set; }
|
||||
public string Icon { get; set; } = string.Empty;
|
||||
public string BadgeColor { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string? Subtitle { get; set; }
|
||||
public decimal? Amount { get; set; }
|
||||
public int? EntityId { get; set; }
|
||||
public string? LinkController { get; set; }
|
||||
public string? LinkAction { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Aggregate lifetime metrics displayed in the CRM stats card on Customer Details.</summary>
|
||||
public class CustomerLifetimeStatsDto
|
||||
{
|
||||
public int TotalJobs { get; set; }
|
||||
public int ActiveJobs { get; set; }
|
||||
/// <summary>Sum of Total on non-voided invoices.</summary>
|
||||
public decimal TotalRevenue { get; set; }
|
||||
/// <summary>Sum of AmountPaid on non-voided invoices.</summary>
|
||||
public decimal TotalCollected { get; set; }
|
||||
/// <summary>Mean FinalPrice across all jobs for this customer.</summary>
|
||||
public decimal AverageJobValue { get; set; }
|
||||
public DateTime? LastJobDate { get; set; }
|
||||
public int? DaysSinceLastJob { get; set; }
|
||||
public int TotalQuotes { get; set; }
|
||||
public int TotalInvoices { get; set; }
|
||||
public decimal OpenBalance { get; set; }
|
||||
/// <summary>Id of the most recent job — used by the "Repeat Last Job" button on Customer Details.</summary>
|
||||
public int? LastJobId { get; set; }
|
||||
}
|
||||
@@ -57,6 +57,7 @@ public class InvoiceDto
|
||||
public string? InternalNotes { get; set; }
|
||||
public string? 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)]
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,7 @@ public interface IUnitOfWork : IDisposable
|
||||
IJobPhotoRepository JobPhotos { get; }
|
||||
IRepository<JobNote> JobNotes { get; }
|
||||
IRepository<CustomerNote> CustomerNotes { get; }
|
||||
IRepository<CustomerPreferredPowder> CustomerPreferredPowders { get; }
|
||||
IRepository<JobStatusHistory> JobStatusHistory { get; }
|
||||
IRepository<PricingTier> PricingTiers { get; }
|
||||
|
||||
|
||||
@@ -230,6 +230,8 @@ 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>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 +553,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 +1723,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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2944,6 +2944,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 +4321,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 +4615,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 +7111,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, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9947),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -7064,7 +7122,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, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9953),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -7075,7 +7133,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, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9954),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
@@ -7385,6 +7443,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)");
|
||||
|
||||
@@ -9485,6 +9546,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")
|
||||
|
||||
@@ -70,6 +70,7 @@ public class UnitOfWork : IUnitOfWork
|
||||
private IJobPhotoRepository? _jobPhotos;
|
||||
private IRepository<JobNote>? _jobNotes;
|
||||
private IRepository<CustomerNote>? _customerNotes;
|
||||
private IRepository<CustomerPreferredPowder>? _customerPreferredPowders;
|
||||
private IRepository<JobStatusHistory>? _jobStatusHistory;
|
||||
private IRepository<PricingTier>? _pricingTiers;
|
||||
|
||||
@@ -321,6 +322,8 @@ public class UnitOfWork : IUnitOfWork
|
||||
/// <summary>Repository for <see cref="CustomerNote"/> free-text staff notes on customer records; tenant-filtered with soft delete.</summary>
|
||||
public IRepository<CustomerNote> CustomerNotes =>
|
||||
_customerNotes ??= new Repository<CustomerNote>(_context);
|
||||
public IRepository<CustomerPreferredPowder> CustomerPreferredPowders =>
|
||||
_customerPreferredPowders ??= new Repository<CustomerPreferredPowder>(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="JobStatusHistory"/> status-transition audit records; tenant-filtered with soft delete.</summary>
|
||||
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()
|
||||
@@ -3051,6 +3071,15 @@ public class CompanySettingsController : Controller
|
||||
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,
|
||||
@@ -3062,7 +3091,7 @@ public class CompanySettingsController : Controller
|
||||
t.Name,
|
||||
t.Description,
|
||||
t.OutputMode,
|
||||
t.FieldsJson,
|
||||
Fields = ParseFields(t.FieldsJson),
|
||||
t.Formula,
|
||||
t.DefaultRate,
|
||||
t.RateLabel,
|
||||
@@ -3142,13 +3171,14 @@ public class CompanySettingsController : Controller
|
||||
Name = name,
|
||||
Description = item.TryGetProperty("description", out var d) ? d.GetString() : null,
|
||||
OutputMode = item.TryGetProperty("outputMode", out var om) ? om.GetString() ?? "FixedRate" : "FixedRate",
|
||||
FieldsJson = item.TryGetProperty("fieldsJson", out var fj) ? fj.GetString() ?? "[]" : "[]",
|
||||
// "fields" is a real JSON array in the export; GetRawText() reconstructs the string
|
||||
FieldsJson = item.TryGetProperty("fields", out var fj) ? fj.GetRawText() : "[]",
|
||||
Formula = item.TryGetProperty("formula", out var f) ? f.GetString() ?? "" : "",
|
||||
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 = item.TryGetProperty("isActive", out var ia) && ia.ValueKind == System.Text.Json.JsonValueKind.True,
|
||||
IsActive = true,
|
||||
};
|
||||
|
||||
var fieldError = ValidateTemplateFields(dto.FieldsJson);
|
||||
|
||||
@@ -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,115 @@ 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;
|
||||
|
||||
// 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 +1049,166 @@ 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." });
|
||||
}
|
||||
}
|
||||
|
||||
/// <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;
|
||||
|
||||
|
||||
@@ -1981,6 +1981,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 +4539,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 +4626,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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -328,6 +328,121 @@
|
||||
</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 +493,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,33 +580,146 @@
|
||||
</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 show p-0"
|
||||
style="display:none!important;position:absolute;z-index:1000;"
|
||||
onfocusout=""></div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<input type="text" id="powderNotes" class="form-control form-control-sm"
|
||||
placeholder="Optional notes (e.g. "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>
|
||||
<style>
|
||||
#powderSearchResults:not(:empty) { display:block!important; max-height:200px; overflow-y:auto; }
|
||||
</style>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
@@ -482,6 +745,17 @@
|
||||
<a asp-controller="Jobs" asp-action="Create" asp-route-customerId="@Model.Id" class="btn btn-outline-success">
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -191,7 +191,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">
|
||||
@@ -629,6 +636,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>
|
||||
|
||||
@@ -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,148 @@ async function cancelSmsConsent() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Customer Notes ────────────────────────────────────────────────────────────
|
||||
|
||||
async function addCustomerNote(customerId) {
|
||||
const textarea = document.getElementById('newNoteText');
|
||||
const importantCb = document.getElementById('newNoteImportant');
|
||||
const note = textarea?.value?.trim();
|
||||
if (!note) { toastr.warning('Please enter a note.'); return; }
|
||||
|
||||
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
try {
|
||||
const res = await fetch(`/Customers/AddCustomerNote/${customerId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tok },
|
||||
body: `note=${encodeURIComponent(note)}&isImportant=${importantCb?.checked ?? false}`
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
const list = document.getElementById('customer-notes-list');
|
||||
const placeholder = document.getElementById('no-notes-placeholder');
|
||||
if (placeholder) placeholder.remove();
|
||||
list.insertAdjacentHTML('afterbegin', data.noteHtml);
|
||||
textarea.value = '';
|
||||
if (importantCb) importantCb.checked = false;
|
||||
toastr.success('Note added.');
|
||||
} else {
|
||||
toastr.error(data.message || 'Could not add note.');
|
||||
}
|
||||
} catch {
|
||||
toastr.error('An error occurred. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCustomerNote(customerId, noteId) {
|
||||
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
try {
|
||||
const res = await fetch(`/Customers/DeleteCustomerNote/${customerId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tok },
|
||||
body: `noteId=${noteId}`
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
document.querySelector(`[data-note-id="${noteId}"]`)?.remove();
|
||||
const list = document.getElementById('customer-notes-list');
|
||||
if (list && list.querySelectorAll('.customer-note-item').length === 0)
|
||||
list.insertAdjacentHTML('afterbegin', '<div id="no-notes-placeholder" class="px-3 py-2 text-muted small">No notes yet.</div>');
|
||||
} else {
|
||||
toastr.error(data.message || 'Could not delete note.');
|
||||
}
|
||||
} catch {
|
||||
toastr.error('An error occurred. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Preferred Powders ─────────────────────────────────────────────────────────
|
||||
|
||||
let _powderSearchTimer = null;
|
||||
|
||||
function searchInventoryItems(term) {
|
||||
clearTimeout(_powderSearchTimer);
|
||||
const dropdown = document.getElementById('powderSearchResults');
|
||||
if (!term || term.length < 2) { if (dropdown) dropdown.innerHTML = ''; return; }
|
||||
|
||||
_powderSearchTimer = setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch(`/Customers/SearchInventoryItems?term=${encodeURIComponent(term)}`);
|
||||
const data = await res.json();
|
||||
if (!dropdown) return;
|
||||
dropdown.innerHTML = data.length === 0
|
||||
? '<div class="dropdown-item text-muted small">No results</div>'
|
||||
: data.map(i => `<button type="button" class="dropdown-item small"
|
||||
onclick="selectPowder(${i.id}, ${JSON.stringify(i.name + (i.colorName ? ' — ' + i.colorName : ''))})">${i.name}${i.colorName ? ' <span class=\'text-muted\'>' + i.colorName + '</span>' : ''} <span class="badge bg-light text-muted border">${i.sku}</span></button>`).join('');
|
||||
} catch { /* silent */ }
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function selectPowder(itemId, label) {
|
||||
document.getElementById('selectedPowderId').value = itemId;
|
||||
document.getElementById('powderSearchInput').value = label;
|
||||
const dropdown = document.getElementById('powderSearchResults');
|
||||
if (dropdown) dropdown.innerHTML = '';
|
||||
}
|
||||
|
||||
async function addPreferredPowder(customerId) {
|
||||
const itemId = document.getElementById('selectedPowderId')?.value;
|
||||
const notes = document.getElementById('powderNotes')?.value?.trim() ?? '';
|
||||
if (!itemId) { toastr.warning('Please search for and select a powder first.'); return; }
|
||||
|
||||
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
try {
|
||||
const res = await fetch(`/Customers/AddPreferredPowder/${customerId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tok },
|
||||
body: `inventoryItemId=${itemId}¬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.');
|
||||
}
|
||||
}
|
||||
|
||||
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