Compare commits
15 Commits
cd4c233b60
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7cbae31916 | |||
| 9367e358d9 | |||
| 9f1460c9c0 | |||
| 94e536178c | |||
| 456d054229 | |||
| f38a1e3273 | |||
| 03b425a12f | |||
| 8453449833 | |||
| ad986561c9 | |||
| 0d5553f3b2 | |||
| 87bbf158a4 | |||
| f453a95f28 | |||
| d9e98a55d2 | |||
| 99deca3b62 | |||
| 23e64829bb |
@@ -57,6 +57,7 @@ public class InvoiceDto
|
|||||||
public string? InternalNotes { get; set; }
|
public string? InternalNotes { get; set; }
|
||||||
public string? Terms { get; set; }
|
public string? Terms { get; set; }
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
public string? ExternalReference { get; set; }
|
public string? ExternalReference { get; set; }
|
||||||
public int? SalesTaxAccountId { get; set; }
|
public int? SalesTaxAccountId { get; set; }
|
||||||
public string? SalesTaxAccountName { get; set; }
|
public string? SalesTaxAccountName { get; set; }
|
||||||
@@ -88,6 +89,7 @@ public class CreateInvoiceDto
|
|||||||
public string? InternalNotes { get; set; }
|
public string? InternalNotes { get; set; }
|
||||||
public string? Terms { get; set; }
|
public string? Terms { get; set; }
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
/// <summary>Early-payment discount percentage parsed from the customer's payment terms (e.g., 2.0 for "2/10 Net 30"). Informational — does not auto-apply.</summary>
|
/// <summary>Early-payment discount percentage parsed from the customer's payment terms (e.g., 2.0 for "2/10 Net 30"). Informational — does not auto-apply.</summary>
|
||||||
public decimal EarlyPaymentDiscountPercent { get; set; }
|
public decimal EarlyPaymentDiscountPercent { get; set; }
|
||||||
/// <summary>Number of days within which the early-payment discount applies (e.g., 10 for "2/10 Net 30").</summary>
|
/// <summary>Number of days within which the early-payment discount applies (e.g., 10 for "2/10 Net 30").</summary>
|
||||||
@@ -105,6 +107,7 @@ public class UpdateInvoiceDto
|
|||||||
public string? InternalNotes { get; set; }
|
public string? InternalNotes { get; set; }
|
||||||
public string? Terms { get; set; }
|
public string? Terms { get; set; }
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
public List<CreateInvoiceItemDto> InvoiceItems { get; set; } = new();
|
public List<CreateInvoiceItemDto> InvoiceItems { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ public class JobDto
|
|||||||
public decimal DiscountValue { get; set; }
|
public decimal DiscountValue { get; set; }
|
||||||
public string? DiscountReason { get; set; }
|
public string? DiscountReason { get; set; }
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
public string? SpecialInstructions { get; set; }
|
public string? SpecialInstructions { get; set; }
|
||||||
public string? InternalNotes { get; set; }
|
public string? InternalNotes { get; set; }
|
||||||
public string? Tags { get; set; }
|
public string? Tags { get; set; }
|
||||||
@@ -113,6 +114,8 @@ public class JobListDto
|
|||||||
|
|
||||||
public string? CustomerEmail { get; set; }
|
public string? CustomerEmail { get; set; }
|
||||||
public bool CustomerNotifyByEmail { get; set; } = true;
|
public bool CustomerNotifyByEmail { get; set; } = true;
|
||||||
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
public DateTime? ScheduledDate { get; set; }
|
public DateTime? ScheduledDate { get; set; }
|
||||||
public DateTime? DueDate { get; set; }
|
public DateTime? DueDate { get; set; }
|
||||||
public decimal FinalPrice { get; set; }
|
public decimal FinalPrice { get; set; }
|
||||||
@@ -166,6 +169,7 @@ public class CreateJobDto
|
|||||||
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
|
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
|
||||||
[Display(Name = "Customer PO")]
|
[Display(Name = "Customer PO")]
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
|
|
||||||
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
|
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
|
||||||
[Display(Name = "Special Instructions")]
|
[Display(Name = "Special Instructions")]
|
||||||
@@ -251,6 +255,7 @@ public class UpdateJobDto
|
|||||||
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
|
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
|
||||||
[Display(Name = "Customer PO")]
|
[Display(Name = "Customer PO")]
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
|
|
||||||
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
|
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
|
||||||
[Display(Name = "Special Instructions")]
|
[Display(Name = "Special Instructions")]
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ public class QuoteDto
|
|||||||
public string? Terms { get; set; }
|
public string? Terms { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
public string? Tags { get; set; }
|
public string? Tags { get; set; }
|
||||||
|
|
||||||
// Items
|
// Items
|
||||||
@@ -234,6 +235,7 @@ public class CreateQuoteDto
|
|||||||
[Display(Name = "Customer PO Number")]
|
[Display(Name = "Customer PO Number")]
|
||||||
[StringLength(50)]
|
[StringLength(50)]
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
|
|
||||||
[Display(Name = "Tags")]
|
[Display(Name = "Tags")]
|
||||||
[StringLength(500)]
|
[StringLength(500)]
|
||||||
@@ -376,6 +378,7 @@ public class UpdateQuoteDto
|
|||||||
[Display(Name = "Customer PO Number")]
|
[Display(Name = "Customer PO Number")]
|
||||||
[StringLength(50)]
|
[StringLength(50)]
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
|
|
||||||
[Display(Name = "Tags")]
|
[Display(Name = "Tags")]
|
||||||
[StringLength(500)]
|
[StringLength(500)]
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public class InvoiceProfile : Profile
|
|||||||
|
|
||||||
CreateMap<Invoice, InvoiceDto>()
|
CreateMap<Invoice, InvoiceDto>()
|
||||||
.ForMember(d => d.JobNumber, o => o.MapFrom(s => s.Job != null ? s.Job.JobNumber : string.Empty))
|
.ForMember(d => d.JobNumber, o => o.MapFrom(s => s.Job != null ? s.Job.JobNumber : string.Empty))
|
||||||
|
.ForMember(d => d.ProjectName, o => o.MapFrom(s => s.ProjectName ?? (s.Job != null ? s.Job.ProjectName : null)))
|
||||||
.ForMember(d => d.CustomerName, o => o.MapFrom(s => s.Customer != null
|
.ForMember(d => d.CustomerName, o => o.MapFrom(s => s.Customer != null
|
||||||
? (s.Customer.IsCommercial
|
? (s.Customer.IsCommercial
|
||||||
? s.Customer.CompanyName
|
? s.Customer.CompanyName
|
||||||
|
|||||||
@@ -217,6 +217,8 @@ public class PdfService : IPdfService
|
|||||||
c.Item().Text($"Job #: {invoice.JobNumber}");
|
c.Item().Text($"Job #: {invoice.JobNumber}");
|
||||||
if (!string.IsNullOrWhiteSpace(invoice.CustomerPO))
|
if (!string.IsNullOrWhiteSpace(invoice.CustomerPO))
|
||||||
c.Item().Text($"PO #: {invoice.CustomerPO}");
|
c.Item().Text($"PO #: {invoice.CustomerPO}");
|
||||||
|
if (!string.IsNullOrWhiteSpace(invoice.ProjectName))
|
||||||
|
c.Item().Text($"Project: {invoice.ProjectName}");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -609,6 +611,15 @@ public class PdfService : IPdfService
|
|||||||
row.RelativeItem().Text(quote.CustomerPO).FontSize(9);
|
row.RelativeItem().Text(quote.CustomerPO).FontSize(9);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(quote.ProjectName))
|
||||||
|
{
|
||||||
|
column.Item().Row(row =>
|
||||||
|
{
|
||||||
|
row.ConstantItem(80).Text("Project:").FontSize(9);
|
||||||
|
row.RelativeItem().Text(quote.ProjectName).FontSize(9);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,145 +5,165 @@ namespace PowderCoating.Application.Services;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Derives sqft/hr throughput rates from a shop's equipment configuration.
|
/// Derives sqft/hr throughput rates from a shop's equipment configuration.
|
||||||
/// Used in two places: the AI photo quote prompt (so Claude reasons from real shop
|
/// Used by the AI photo quote prompt (so Claude reasons from real shop speeds)
|
||||||
/// speeds) and the calculated-item wizard (to show a suggested blast time hint).
|
/// and the Company Settings live preview (so the UI always shows the same rate
|
||||||
|
/// the AI will use — single formula path, no client-side duplication).
|
||||||
///
|
///
|
||||||
/// Formula:
|
/// Both pressure pots and siphon cabinets are nozzle-primary: nozzle size
|
||||||
/// BlastRate = BaseByCfm(cfm) × NozzleMultiplier × SetupMultiplier × SubstrateMultiplier
|
/// determines throughput and CFM draw. CFM is not used in the rate formula.
|
||||||
///
|
///
|
||||||
/// Base rates by CFM represent a pressure pot at #5 nozzle removing paint.
|
/// Sources:
|
||||||
/// All multipliers are relative to that baseline.
|
/// Pressure pot rates — averaged from two industry standard abrasive blast
|
||||||
|
/// cleaning reference tables.
|
||||||
|
/// Siphon cabinet rates — industry reference table for siphon-fed cabinets.
|
||||||
|
/// Substrate multipliers — relative removal difficulty vs. paint baseline.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ShopCapabilityCalculator
|
public static class ShopCapabilityCalculator
|
||||||
{
|
{
|
||||||
// ── Blast rate derivation ─────────────────────────────────────────────────
|
// ── Public entry points ────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the effective blast rate in sqft/hr.
|
/// Returns the effective blast rate in sqft/hr for company-level operating costs.
|
||||||
/// If <see cref="CompanyOperatingCosts.BlastRateSqFtPerHourOverride"/> is set, returns it directly.
|
/// BlastRateSqFtPerHourOverride bypasses the formula when set.
|
||||||
/// Otherwise derives from CFM, nozzle, setup type, and substrate.
|
|
||||||
/// Returns 0 when CFM is not configured (shop hasn't calibrated yet).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static decimal GetBlastRateSqFtPerHour(CompanyOperatingCosts costs)
|
public static decimal GetBlastRateSqFtPerHour(CompanyOperatingCosts costs)
|
||||||
{
|
{
|
||||||
if (costs.BlastRateSqFtPerHourOverride.HasValue && costs.BlastRateSqFtPerHourOverride.Value > 0)
|
if (costs.BlastRateSqFtPerHourOverride.HasValue && costs.BlastRateSqFtPerHourOverride.Value > 0)
|
||||||
return costs.BlastRateSqFtPerHourOverride.Value;
|
return costs.BlastRateSqFtPerHourOverride.Value;
|
||||||
|
|
||||||
if (costs.CompressorCfm <= 0)
|
return CalculateBlastRate(costs.BlastNozzleSize, costs.BlastSetupType, costs.PrimaryBlastSubstrate);
|
||||||
return 0m;
|
|
||||||
|
|
||||||
var baseRate = BaseByCfm(costs.CompressorCfm);
|
|
||||||
var nozzle = NozzleMultiplier(costs.BlastNozzleSize);
|
|
||||||
var setup = SetupMultiplier(costs.BlastSetupType);
|
|
||||||
var substrate = SubstrateMultiplier(costs.PrimaryBlastSubstrate);
|
|
||||||
|
|
||||||
return Math.Round(baseRate * nozzle * setup * substrate, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the effective blast rate in sqft/hr for a named <see cref="CompanyBlastSetup"/>.
|
/// Returns the effective blast rate in sqft/hr for a named blast setup.
|
||||||
/// Identical logic to the <see cref="CompanyOperatingCosts"/> overload — uses override if set,
|
/// BlastRateSqFtPerHourOverride bypasses the formula when set.
|
||||||
/// otherwise derives from the setup's equipment specs.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static decimal GetBlastRateSqFtPerHour(CompanyBlastSetup setup)
|
public static decimal GetBlastRateSqFtPerHour(CompanyBlastSetup setup)
|
||||||
{
|
{
|
||||||
if (setup.BlastRateSqFtPerHourOverride.HasValue && setup.BlastRateSqFtPerHourOverride.Value > 0)
|
if (setup.BlastRateSqFtPerHourOverride.HasValue && setup.BlastRateSqFtPerHourOverride.Value > 0)
|
||||||
return setup.BlastRateSqFtPerHourOverride.Value;
|
return setup.BlastRateSqFtPerHourOverride.Value;
|
||||||
|
|
||||||
if (setup.CompressorCfm <= 0)
|
return CalculateBlastRate(setup.BlastNozzleSize, setup.SetupType, setup.PrimarySubstrate);
|
||||||
return 0m;
|
|
||||||
|
|
||||||
var baseRate = BaseByCfm(setup.CompressorCfm);
|
|
||||||
var nozzle = NozzleMultiplier(setup.BlastNozzleSize);
|
|
||||||
var setupMult = SetupMultiplier(setup.SetupType);
|
|
||||||
var substrate = SubstrateMultiplier(setup.PrimarySubstrate);
|
|
||||||
|
|
||||||
return Math.Round(baseRate * nozzle * setupMult * substrate, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the effective coating application rate in sqft/hr.
|
/// Returns the effective coating application rate in sqft/hr.
|
||||||
/// If override is set, returns it directly.
|
/// Override bypasses the formula when set.
|
||||||
/// Otherwise derives a sensible default from gun type.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static decimal GetCoatingRateSqFtPerHour(CompanyOperatingCosts costs)
|
public static decimal GetCoatingRateSqFtPerHour(CompanyOperatingCosts costs)
|
||||||
{
|
{
|
||||||
if (costs.CoatingRateSqFtPerHourOverride.HasValue && costs.CoatingRateSqFtPerHourOverride.Value > 0)
|
if (costs.CoatingRateSqFtPerHourOverride.HasValue && costs.CoatingRateSqFtPerHourOverride.Value > 0)
|
||||||
return costs.CoatingRateSqFtPerHourOverride.Value;
|
return costs.CoatingRateSqFtPerHourOverride.Value;
|
||||||
|
|
||||||
// Corona and tribo guns are roughly similar on flat parts; tribo edges out on complex geometry.
|
|
||||||
// Without more equipment data (voltage, gun model) we use a single reasonable default.
|
|
||||||
return costs.CoatingGunType switch
|
return costs.CoatingGunType switch
|
||||||
{
|
{
|
||||||
CoatingGunType.Corona => 40m,
|
CoatingGunType.Corona => 40m,
|
||||||
CoatingGunType.Tribo => 35m, // slower on flat but better on complex; conservative default
|
CoatingGunType.Tribo => 35m,
|
||||||
CoatingGunType.Both => 40m,
|
CoatingGunType.Both => 40m,
|
||||||
_ => 40m
|
_ => 40m
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns default equipment field values for a given capability tier.
|
/// Returns default equipment field values for a given capability tier, applied
|
||||||
/// Applied during Setup Wizard tier selection so the shop gets reasonable
|
/// during Setup Wizard tier selection so new shops get reasonable starting values.
|
||||||
/// starting values even if they never visit the Quoting Calibration tab.
|
/// CFM defaults reflect typical compressor sizes for each tier; they appear in the
|
||||||
|
/// UI for reference but are not used in the rate formula.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static (BlastSetupType SetupType, decimal Cfm, int NozzleSize, BlastSubstrateType Substrate)
|
public static (BlastSetupType SetupType, decimal Cfm, int NozzleSize, BlastSubstrateType Substrate)
|
||||||
TierDefaults(ShopCapabilityTier tier) => tier switch
|
TierDefaults(ShopCapabilityTier tier) => tier switch
|
||||||
{
|
{
|
||||||
ShopCapabilityTier.Garage => (BlastSetupType.SiphonCabinet, 7m, 4, BlastSubstrateType.Mixed),
|
ShopCapabilityTier.Garage => (BlastSetupType.SiphonCabinet, 7m, 3, BlastSubstrateType.Mixed),
|
||||||
ShopCapabilityTier.Small => (BlastSetupType.PressurePot, 40m, 5, BlastSubstrateType.Mixed),
|
ShopCapabilityTier.Small => (BlastSetupType.PressurePot, 49m, 3, BlastSubstrateType.Mixed),
|
||||||
ShopCapabilityTier.Medium => (BlastSetupType.PressurePot, 80m, 5, BlastSubstrateType.Mixed),
|
ShopCapabilityTier.Medium => (BlastSetupType.PressurePot, 90m, 4, BlastSubstrateType.Mixed),
|
||||||
ShopCapabilityTier.Large => (BlastSetupType.PressurePot, 150m, 6, BlastSubstrateType.Mixed),
|
ShopCapabilityTier.Large => (BlastSetupType.PressurePot, 150m, 5, BlastSubstrateType.Mixed),
|
||||||
_ => (BlastSetupType.SiphonCabinet, 7m, 4, BlastSubstrateType.Mixed)
|
_ => (BlastSetupType.SiphonCabinet, 7m, 3, BlastSubstrateType.Mixed)
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Private helpers ───────────────────────────────────────────────────────
|
// ── Core formula (single path for all callers) ─────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Base sqft/hr at a pressure pot, #5 nozzle, removing paint.
|
/// Nozzle-primary blast rate calculation. Nozzle size determines throughput;
|
||||||
/// Calibrated so that real-world examples produce expected results:
|
/// setup type routes to the appropriate reference table; substrate adjusts for
|
||||||
/// - 7 CFM siphon cabinet → ~2 sqft/hr (garage coater, 3+ hrs/wheel)
|
/// removal difficulty. CFM is not used — it is a consequence of nozzle choice,
|
||||||
/// - 40 CFM pressure pot → ~15 sqft/hr (small shop, ~30 min/wheel)
|
/// not an independent variable in throughput.
|
||||||
/// - 80 CFM pressure pot → ~25 sqft/hr (medium shop)
|
|
||||||
/// - 150 CFM pressure pot → ~40 sqft/hr (large shop, ~10 min/wheel)
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static decimal BaseByCfm(decimal cfm) => cfm switch
|
private static decimal CalculateBlastRate(int nozzle, BlastSetupType setupType, BlastSubstrateType substrate)
|
||||||
{
|
{
|
||||||
< 10 => 5m,
|
var baseRate = setupType switch
|
||||||
< 20 => 9m,
|
{
|
||||||
< 40 => 15m,
|
BlastSetupType.PressurePot => PressurePotRateByNozzle(nozzle),
|
||||||
< 80 => 25m,
|
BlastSetupType.SiphonCabinet => SiphonCabinetRateByNozzle(nozzle),
|
||||||
< 120 => 35m,
|
// Siphon pot: open gravity feed, no enclosure penalty, ~80% of pressure pot
|
||||||
_ => 45m
|
BlastSetupType.SiphonPot => Math.Round(PressurePotRateByNozzle(nozzle) * 0.80m, 1),
|
||||||
|
// Wet blasting: water-media mix reduces impact velocity, ~60% of dry pressure pot
|
||||||
|
BlastSetupType.WetBlasting => Math.Round(PressurePotRateByNozzle(nozzle) * 0.60m, 1),
|
||||||
|
_ => 0m
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
1 => 18m,
|
||||||
3 => 0.55m,
|
2 => 38m,
|
||||||
4 => 0.75m,
|
3 => 75m,
|
||||||
5 => 1.00m,
|
4 => 125m,
|
||||||
6 => 1.30m,
|
5 => 188m,
|
||||||
7 => 1.65m,
|
6 => 263m,
|
||||||
8 => 2.00m,
|
7 => 338m,
|
||||||
_ => 1.00m
|
8 => 413m,
|
||||||
};
|
_ => 80m
|
||||||
|
|
||||||
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
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adjustment for substrate removal difficulty relative to paint (baseline = 1.0).
|
||||||
|
/// Powder coat strips faster than paint; rust and scale requires multiple passes.
|
||||||
|
/// </summary>
|
||||||
private static decimal SubstrateMultiplier(BlastSubstrateType substrate) => substrate switch
|
private static decimal SubstrateMultiplier(BlastSubstrateType substrate) => substrate switch
|
||||||
{
|
{
|
||||||
BlastSubstrateType.PowderCoat => 1.25m, // faster to remove than paint
|
BlastSubstrateType.PowderCoat => 1.25m,
|
||||||
BlastSubstrateType.Paint => 1.00m, // baseline
|
BlastSubstrateType.Paint => 1.00m,
|
||||||
BlastSubstrateType.Mixed => 0.90m,
|
BlastSubstrateType.Mixed => 0.90m,
|
||||||
BlastSubstrateType.RustAndScale => 0.70m, // requires more passes
|
BlastSubstrateType.RustAndScale => 0.70m,
|
||||||
_ => 0.90m
|
_ => 0.90m
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ public class Invoice : BaseEntity
|
|||||||
public string? InternalNotes { get; set; }
|
public string? InternalNotes { get; set; }
|
||||||
public string? Terms { get; set; }
|
public string? Terms { get; set; }
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Early payment discount percentage (e.g., 2 means 2% discount).
|
/// Early payment discount percentage (e.g., 2 means 2% discount).
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ public class Job : BaseEntity
|
|||||||
|
|
||||||
// Additional Information
|
// Additional Information
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
public string? SpecialInstructions { get; set; }
|
public string? SpecialInstructions { get; set; }
|
||||||
public string? InternalNotes { get; set; } // Internal notes from quote
|
public string? InternalNotes { get; set; } // Internal notes from quote
|
||||||
public string? Tags { get; set; }
|
public string? Tags { get; set; }
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ public class Quote : BaseEntity
|
|||||||
public string? Terms { get; set; }
|
public string? Terms { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
public string? Tags { get; set; }
|
public string? Tags { get; set; }
|
||||||
|
|
||||||
// Conversion tracking
|
// Conversion tracking
|
||||||
|
|||||||
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4269,6 +4269,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("PreparedById")
|
b.Property<string>("PreparedById")
|
||||||
.HasColumnType("nvarchar(450)");
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("ProjectName")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("PublicViewToken")
|
b.Property<string>("PublicViewToken")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -4560,6 +4563,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("PricingBreakdownJson")
|
b.Property<string>("PricingBreakdownJson")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ProjectName")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<int?>("QuoteId")
|
b.Property<int?>("QuoteId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -7053,7 +7059,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9377),
|
CreatedAt = new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2471),
|
||||||
Description = "Standard pricing for regular customers",
|
Description = "Standard pricing for regular customers",
|
||||||
DiscountPercent = 0m,
|
DiscountPercent = 0m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -7064,7 +7070,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9381),
|
CreatedAt = new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2477),
|
||||||
Description = "5% discount for preferred customers",
|
Description = "5% discount for preferred customers",
|
||||||
DiscountPercent = 5m,
|
DiscountPercent = 5m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -7075,7 +7081,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9382),
|
CreatedAt = new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2478),
|
||||||
Description = "10% discount for premium customers",
|
Description = "10% discount for premium customers",
|
||||||
DiscountPercent = 10m,
|
DiscountPercent = 10m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -7385,6 +7391,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<decimal>("ProfitPercent")
|
b.Property<decimal>("ProfitPercent")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<string>("ProjectName")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("ProspectAddress")
|
b.Property<string>("ProspectAddress")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ public class NotificationService : INotificationService
|
|||||||
quote.CompanyId, NotificationType.QuoteSent, values,
|
quote.CompanyId, NotificationType.QuoteSent, values,
|
||||||
$"Your Quote {quote.QuoteNumber} from {companyName}");
|
$"Your Quote {quote.QuoteNumber} from {companyName}");
|
||||||
|
|
||||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, baseUrl);
|
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, baseUrl, replyToEmail);
|
||||||
var plainText = StripHtml(fullHtml);
|
var plainText = StripHtml(fullHtml);
|
||||||
|
|
||||||
var (success, error) = await _emailService.SendEmailAsync(
|
var (success, error) = await _emailService.SendEmailAsync(
|
||||||
@@ -137,7 +137,7 @@ public class NotificationService : INotificationService
|
|||||||
quote.CompanyId, NotificationType.QuoteSent, values,
|
quote.CompanyId, NotificationType.QuoteSent, values,
|
||||||
$"Your Quote {quote.QuoteNumber} from {companyName}");
|
$"Your Quote {quote.QuoteNumber} from {companyName}");
|
||||||
|
|
||||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, baseUrl);
|
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, baseUrl, replyToEmail);
|
||||||
var plainText = StripHtml(fullHtml);
|
var plainText = StripHtml(fullHtml);
|
||||||
|
|
||||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||||
@@ -300,7 +300,7 @@ public class NotificationService : INotificationService
|
|||||||
quote.CompanyId, NotificationType.QuoteApproved, values,
|
quote.CompanyId, NotificationType.QuoteApproved, values,
|
||||||
$"Quote {quote.QuoteNumber} Approved — {companyName}");
|
$"Quote {quote.QuoteNumber} Approved — {companyName}");
|
||||||
|
|
||||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
|
||||||
var plainText = StripHtml(fullHtml);
|
var plainText = StripHtml(fullHtml);
|
||||||
|
|
||||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||||
@@ -383,7 +383,7 @@ public class NotificationService : INotificationService
|
|||||||
var (subject, htmlBody) = await GetRenderedEmailAsync(
|
var (subject, htmlBody) = await GetRenderedEmailAsync(
|
||||||
job.CompanyId, notifType, values, defaultSubject);
|
job.CompanyId, notifType, values, defaultSubject);
|
||||||
|
|
||||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
|
||||||
var plainText = StripHtml(fullHtml);
|
var plainText = StripHtml(fullHtml);
|
||||||
|
|
||||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||||
@@ -451,7 +451,7 @@ public class NotificationService : INotificationService
|
|||||||
job.CompanyId, NotificationType.JobCompleted, values,
|
job.CompanyId, NotificationType.JobCompleted, values,
|
||||||
$"Job {job.JobNumber} Complete — {companyName}");
|
$"Job {job.JobNumber} Complete — {companyName}");
|
||||||
|
|
||||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
|
||||||
var plainText = StripHtml(fullHtml);
|
var plainText = StripHtml(fullHtml);
|
||||||
|
|
||||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||||
@@ -674,7 +674,7 @@ public class NotificationService : INotificationService
|
|||||||
""";
|
""";
|
||||||
}
|
}
|
||||||
|
|
||||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
|
||||||
var plainText = !string.IsNullOrEmpty(paymentUrl)
|
var plainText = !string.IsNullOrEmpty(paymentUrl)
|
||||||
? StripHtml(htmlBody) + $"\r\n\r\nPay online: {paymentUrl}"
|
? StripHtml(htmlBody) + $"\r\n\r\nPay online: {paymentUrl}"
|
||||||
: StripHtml(fullHtml);
|
: StripHtml(fullHtml);
|
||||||
@@ -793,7 +793,7 @@ public class NotificationService : INotificationService
|
|||||||
invoice.CompanyId, NotificationType.PaymentReceived, values,
|
invoice.CompanyId, NotificationType.PaymentReceived, values,
|
||||||
$"Payment Received — Invoice {invoice.InvoiceNumber}");
|
$"Payment Received — Invoice {invoice.InvoiceNumber}");
|
||||||
|
|
||||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
|
||||||
var plainText = StripHtml(fullHtml);
|
var plainText = StripHtml(fullHtml);
|
||||||
|
|
||||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||||
@@ -867,7 +867,7 @@ public class NotificationService : INotificationService
|
|||||||
invoice.CompanyId, NotificationType.PaymentReminder, values,
|
invoice.CompanyId, NotificationType.PaymentReminder, values,
|
||||||
$"Payment Reminder — Invoice {invoice.InvoiceNumber} ({daysOverdue} days overdue)");
|
$"Payment Reminder — Invoice {invoice.InvoiceNumber} ({daysOverdue} days overdue)");
|
||||||
|
|
||||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
|
||||||
var plainText = StripHtml(fullHtml);
|
var plainText = StripHtml(fullHtml);
|
||||||
|
|
||||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||||
@@ -971,7 +971,7 @@ public class NotificationService : INotificationService
|
|||||||
var (subject, htmlBody) = await GetRenderedEmailAsync(
|
var (subject, htmlBody) = await GetRenderedEmailAsync(
|
||||||
quote.CompanyId, notificationType, values, defaultSubject);
|
quote.CompanyId, notificationType, values, defaultSubject);
|
||||||
|
|
||||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, await GetBaseUrlAsync());
|
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, await GetBaseUrlAsync(), replyToEmail);
|
||||||
var plainText = StripHtml(fullHtml);
|
var plainText = StripHtml(fullHtml);
|
||||||
|
|
||||||
var (success, error) = await _emailService.SendEmailAsync(
|
var (success, error) = await _emailService.SendEmailAsync(
|
||||||
@@ -1218,7 +1218,7 @@ public class NotificationService : INotificationService
|
|||||||
var (custSubject, custHtml) = await GetRenderedEmailAsync(
|
var (custSubject, custHtml) = await GetRenderedEmailAsync(
|
||||||
appointment.CompanyId, NotificationType.AppointmentReminder, customerValues, defaultSubject);
|
appointment.CompanyId, NotificationType.AppointmentReminder, customerValues, defaultSubject);
|
||||||
|
|
||||||
var custFullHtml = AppendUnsubscribeFooterHtml(custHtml, customer.UnsubscribeToken, company, baseUrl);
|
var custFullHtml = AppendUnsubscribeFooterHtml(custHtml, customer.UnsubscribeToken, company, baseUrl, replyToEmail);
|
||||||
var custPlainText = StripHtml(custFullHtml);
|
var custPlainText = StripHtml(custFullHtml);
|
||||||
|
|
||||||
var (custOk, custErr, custLog) = await SendToEmailListAsync(
|
var (custOk, custErr, custLog) = await SendToEmailListAsync(
|
||||||
@@ -1388,17 +1388,25 @@ public class NotificationService : INotificationService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Appends CAN-SPAM required footer as HTML.
|
/// Appends CAN-SPAM required footer as HTML.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static string AppendUnsubscribeFooterHtml(string htmlBody, string? token, Company? company = null, string? baseUrl = null)
|
private static string AppendUnsubscribeFooterHtml(string htmlBody, string? token, Company? company = null, string? baseUrl = null, string? replyToEmail = null)
|
||||||
{
|
{
|
||||||
var hasUnsubscribeUrl = !string.IsNullOrEmpty(token) && !string.IsNullOrEmpty(baseUrl);
|
var hasUnsubscribeUrl = !string.IsNullOrEmpty(token) && !string.IsNullOrEmpty(baseUrl);
|
||||||
var hasAddress = company != null && !string.IsNullOrWhiteSpace(company.Address);
|
var hasAddress = company != null && !string.IsNullOrWhiteSpace(company.Address);
|
||||||
|
var hasReplyTo = !string.IsNullOrWhiteSpace(replyToEmail);
|
||||||
|
|
||||||
if (!hasUnsubscribeUrl && !hasAddress)
|
if (!hasUnsubscribeUrl && !hasAddress && !hasReplyTo)
|
||||||
return htmlBody;
|
return htmlBody;
|
||||||
|
|
||||||
var footer = "<hr style=\"border: none; border-top: 1px solid #eee; margin: 24px 0;\">" +
|
var footer = "<hr style=\"border: none; border-top: 1px solid #eee; margin: 24px 0;\">" +
|
||||||
"<p style=\"font-size: 0.8em; color: #888; margin: 0;\">";
|
"<p style=\"font-size: 0.8em; color: #888; margin: 0;\">";
|
||||||
|
|
||||||
|
if (hasReplyTo)
|
||||||
|
{
|
||||||
|
var encodedEmail = WebUtility.HtmlEncode(replyToEmail!);
|
||||||
|
footer += $"Questions? Reply to this email or contact us at <a href=\"mailto:{encodedEmail}\" style=\"color: #888;\">{encodedEmail}</a>";
|
||||||
|
if (hasAddress || hasUnsubscribeUrl) footer += "<br>";
|
||||||
|
}
|
||||||
|
|
||||||
if (hasAddress)
|
if (hasAddress)
|
||||||
{
|
{
|
||||||
var addressLine = BuildAddressLine(company!);
|
var addressLine = BuildAddressLine(company!);
|
||||||
@@ -1535,7 +1543,15 @@ public class NotificationService : INotificationService
|
|||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
||||||
|
|
||||||
return (prefs?.EmailFromAddress, prefs?.EmailFromName);
|
var email = prefs?.EmailFromAddress;
|
||||||
|
var name = prefs?.EmailFromName;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(email))
|
||||||
|
_logger.LogWarning("No Reply-To email configured for company {CompanyId} — outgoing emails will show platform sender as reply address", companyId);
|
||||||
|
else
|
||||||
|
_logger.LogDebug("Reply-To for company {CompanyId}: {ReplyToEmail}", companyId, email);
|
||||||
|
|
||||||
|
return (email, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1726,6 +1726,26 @@ public class CompanySettingsController : Controller
|
|||||||
|
|
||||||
#region Blast Setups
|
#region Blast Setups
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single authoritative blast-rate calculation endpoint. Takes equipment parameters and
|
||||||
|
/// returns the sqft/hr rate using the same ShopCapabilityCalculator formula the AI uses.
|
||||||
|
/// The modal live preview calls this instead of duplicating the formula in JavaScript.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
public IActionResult DeriveBlastRate(decimal cfm, int nozzle, int setupType, int substrate, decimal? rateOverride)
|
||||||
|
{
|
||||||
|
var setup = new CompanyBlastSetup
|
||||||
|
{
|
||||||
|
CompressorCfm = cfm,
|
||||||
|
BlastNozzleSize = nozzle,
|
||||||
|
SetupType = (BlastSetupType)setupType,
|
||||||
|
PrimarySubstrate = (BlastSubstrateType)substrate,
|
||||||
|
BlastRateSqFtPerHourOverride = rateOverride > 0 ? rateOverride : null
|
||||||
|
};
|
||||||
|
var rate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(setup);
|
||||||
|
return Json(new { rate });
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Returns all active blast setups for the current company with their derived rates.</summary>
|
/// <summary>Returns all active blast setups for the current company with their derived rates.</summary>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> GetBlastSetups()
|
public async Task<IActionResult> GetBlastSetups()
|
||||||
@@ -3051,6 +3071,15 @@ public class CompanySettingsController : Controller
|
|||||||
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||||
var templates = await _unitOfWork.CustomItemTemplates.FindAsync(t => t.CompanyId == companyId);
|
var templates = await _unitOfWork.CustomItemTemplates.FindAsync(t => t.CompanyId == companyId);
|
||||||
|
|
||||||
|
// Parse FieldsJson into a real JsonElement so it is embedded as a proper JSON array
|
||||||
|
// in the export file rather than as an escaped string. This makes the file human-readable
|
||||||
|
// and avoids round-trip corruption when files are manually edited.
|
||||||
|
static System.Text.Json.JsonElement ParseFields(string? raw)
|
||||||
|
{
|
||||||
|
try { return System.Text.Json.JsonDocument.Parse(raw ?? "[]").RootElement.Clone(); }
|
||||||
|
catch { return System.Text.Json.JsonDocument.Parse("[]").RootElement.Clone(); }
|
||||||
|
}
|
||||||
|
|
||||||
var export = new
|
var export = new
|
||||||
{
|
{
|
||||||
exportedAt = DateTime.UtcNow,
|
exportedAt = DateTime.UtcNow,
|
||||||
@@ -3062,7 +3091,7 @@ public class CompanySettingsController : Controller
|
|||||||
t.Name,
|
t.Name,
|
||||||
t.Description,
|
t.Description,
|
||||||
t.OutputMode,
|
t.OutputMode,
|
||||||
t.FieldsJson,
|
Fields = ParseFields(t.FieldsJson),
|
||||||
t.Formula,
|
t.Formula,
|
||||||
t.DefaultRate,
|
t.DefaultRate,
|
||||||
t.RateLabel,
|
t.RateLabel,
|
||||||
@@ -3142,13 +3171,14 @@ public class CompanySettingsController : Controller
|
|||||||
Name = name,
|
Name = name,
|
||||||
Description = item.TryGetProperty("description", out var d) ? d.GetString() : null,
|
Description = item.TryGetProperty("description", out var d) ? d.GetString() : null,
|
||||||
OutputMode = item.TryGetProperty("outputMode", out var om) ? om.GetString() ?? "FixedRate" : "FixedRate",
|
OutputMode = item.TryGetProperty("outputMode", out var om) ? om.GetString() ?? "FixedRate" : "FixedRate",
|
||||||
FieldsJson = item.TryGetProperty("fieldsJson", out var fj) ? fj.GetString() ?? "[]" : "[]",
|
// "fields" is a real JSON array in the export; GetRawText() reconstructs the string
|
||||||
|
FieldsJson = item.TryGetProperty("fields", out var fj) ? fj.GetRawText() : "[]",
|
||||||
Formula = item.TryGetProperty("formula", out var f) ? f.GetString() ?? "" : "",
|
Formula = item.TryGetProperty("formula", out var f) ? f.GetString() ?? "" : "",
|
||||||
DefaultRate = item.TryGetProperty("defaultRate", out var dr) && dr.ValueKind == System.Text.Json.JsonValueKind.Number ? dr.GetDecimal() : null,
|
DefaultRate = item.TryGetProperty("defaultRate", out var dr) && dr.ValueKind == System.Text.Json.JsonValueKind.Number ? dr.GetDecimal() : null,
|
||||||
RateLabel = item.TryGetProperty("rateLabel", out var rl) ? rl.GetString() : null,
|
RateLabel = item.TryGetProperty("rateLabel", out var rl) ? rl.GetString() : null,
|
||||||
Notes = item.TryGetProperty("notes", out var n) ? n.GetString() : null,
|
Notes = item.TryGetProperty("notes", out var n) ? n.GetString() : null,
|
||||||
DisplayOrder = item.TryGetProperty("displayOrder", out var dord) && dord.ValueKind == System.Text.Json.JsonValueKind.Number ? dord.GetInt32() : 0,
|
DisplayOrder = item.TryGetProperty("displayOrder", out var dord) && dord.ValueKind == System.Text.Json.JsonValueKind.Number ? dord.GetInt32() : 0,
|
||||||
IsActive = item.TryGetProperty("isActive", out var ia) && ia.ValueKind == System.Text.Json.JsonValueKind.True,
|
IsActive = true,
|
||||||
};
|
};
|
||||||
|
|
||||||
var fieldError = ValidateTemplateFields(dto.FieldsJson);
|
var fieldError = ValidateTemplateFields(dto.FieldsJson);
|
||||||
|
|||||||
@@ -1642,8 +1642,10 @@ public class InventoryController : Controller
|
|||||||
|
|
||||||
var userId = _userManager.GetUserId(User);
|
var userId = _userManager.GetUserId(User);
|
||||||
|
|
||||||
|
var recentCutoff = DateTime.UtcNow.AddDays(-7);
|
||||||
|
|
||||||
var myJobs = (await _unitOfWork.Jobs.FindAsync(
|
var myJobs = (await _unitOfWork.Jobs.FindAsync(
|
||||||
j => !j.JobStatus.IsTerminalStatus && j.AssignedUserId == userId,
|
j => (!j.JobStatus.IsTerminalStatus || j.UpdatedAt >= recentCutoff) && j.AssignedUserId == userId,
|
||||||
false,
|
false,
|
||||||
j => j.Customer,
|
j => j.Customer,
|
||||||
j => j.JobStatus))
|
j => j.JobStatus))
|
||||||
@@ -1651,7 +1653,7 @@ public class InventoryController : Controller
|
|||||||
.Select(j => new ScanJobOption
|
.Select(j => new ScanJobOption
|
||||||
{
|
{
|
||||||
Id = j.Id,
|
Id = j.Id,
|
||||||
JobNumber = j.JobNumber,
|
JobNumber = j.JobNumber + (j.JobStatus.IsTerminalStatus ? " (completed)" : ""),
|
||||||
CustomerName = j.Customer != null
|
CustomerName = j.Customer != null
|
||||||
? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName)
|
? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName)
|
||||||
: "No Customer"
|
: "No Customer"
|
||||||
@@ -1660,7 +1662,7 @@ public class InventoryController : Controller
|
|||||||
|
|
||||||
var myJobIds = myJobs.Select(j => j.Id).ToHashSet();
|
var myJobIds = myJobs.Select(j => j.Id).ToHashSet();
|
||||||
var otherJobs = (await _unitOfWork.Jobs.FindAsync(
|
var otherJobs = (await _unitOfWork.Jobs.FindAsync(
|
||||||
j => !j.JobStatus.IsTerminalStatus && !myJobIds.Contains(j.Id),
|
j => (!j.JobStatus.IsTerminalStatus || j.UpdatedAt >= recentCutoff) && !myJobIds.Contains(j.Id),
|
||||||
false,
|
false,
|
||||||
j => j.Customer,
|
j => j.Customer,
|
||||||
j => j.JobStatus))
|
j => j.JobStatus))
|
||||||
@@ -1669,7 +1671,7 @@ public class InventoryController : Controller
|
|||||||
.Select(j => new ScanJobOption
|
.Select(j => new ScanJobOption
|
||||||
{
|
{
|
||||||
Id = j.Id,
|
Id = j.Id,
|
||||||
JobNumber = j.JobNumber,
|
JobNumber = j.JobNumber + (j.JobStatus.IsTerminalStatus ? " (completed)" : ""),
|
||||||
CustomerName = j.Customer != null
|
CustomerName = j.Customer != null
|
||||||
? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName)
|
? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName)
|
||||||
: "No Customer"
|
: "No Customer"
|
||||||
@@ -1686,9 +1688,64 @@ public class InventoryController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Records powder usage logged via the mobile scan page. Creates a JobUsage
|
/// Core inventory usage recording logic shared by LogUsage (scan page) and LogMaterial (modal).
|
||||||
/// InventoryTransaction (and PowderUsageLog) when a job is selected, or an
|
/// Deducts quantityUsed from QuantityOnHand, writes an InventoryTransaction, and posts GL entries.
|
||||||
/// Adjustment transaction when logging without a job. Updates QuantityOnHand.
|
/// </summary>
|
||||||
|
private async Task<InventoryUsageResult> RecordInventoryUsageAsync(
|
||||||
|
int inventoryItemId, int? jobId, decimal quantityUsed,
|
||||||
|
InventoryTransactionType transactionType, string? notes)
|
||||||
|
{
|
||||||
|
var item = await _unitOfWork.InventoryItems.GetByIdAsync(inventoryItemId);
|
||||||
|
if (item == null)
|
||||||
|
return new InventoryUsageResult(false, "Inventory item not found.", 0, "", "");
|
||||||
|
|
||||||
|
string? reference = null;
|
||||||
|
if (jobId.HasValue)
|
||||||
|
{
|
||||||
|
var job = await _unitOfWork.Jobs.GetByIdAsync(jobId.Value);
|
||||||
|
reference = job != null ? $"Job {job.JobNumber}" : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.QuantityOnHand -= quantityUsed;
|
||||||
|
item.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _unitOfWork.InventoryItems.UpdateAsync(item);
|
||||||
|
|
||||||
|
var txn = new InventoryTransaction
|
||||||
|
{
|
||||||
|
InventoryItemId = item.Id,
|
||||||
|
TransactionType = transactionType,
|
||||||
|
Quantity = -quantityUsed,
|
||||||
|
UnitCost = item.UnitCost,
|
||||||
|
TotalCost = quantityUsed * item.UnitCost,
|
||||||
|
TransactionDate = DateTime.UtcNow,
|
||||||
|
BalanceAfter = item.QuantityOnHand,
|
||||||
|
JobId = jobId,
|
||||||
|
Reference = reference,
|
||||||
|
Notes = notes?.Trim(),
|
||||||
|
CompanyId = item.CompanyId,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
await _unitOfWork.InventoryTransactions.AddAsync(txn);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue)
|
||||||
|
{
|
||||||
|
var cost = quantityUsed * (item.AverageCost > 0 ? item.AverageCost : item.UnitCost);
|
||||||
|
await _accountBalanceService.DebitAsync(item.CogsAccountId, cost);
|
||||||
|
await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new InventoryUsageResult(
|
||||||
|
true,
|
||||||
|
$"Logged {quantityUsed:N2} {item.UnitOfMeasure} of {item.Name}. New balance: {item.QuantityOnHand:N2} {item.UnitOfMeasure}.",
|
||||||
|
item.QuantityOnHand,
|
||||||
|
item.UnitOfMeasure,
|
||||||
|
item.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records powder usage from the mobile scan page. Resolves the used quantity
|
||||||
|
/// (caller already converts "remaining weight" to delta before posting) and redirects to ScanSuccess.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
@@ -1697,55 +1754,26 @@ public class InventoryController : Controller
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var item = await _unitOfWork.InventoryItems.GetByIdAsync(inventoryItemId);
|
|
||||||
if (item == null) return NotFound();
|
|
||||||
|
|
||||||
if (quantity <= 0)
|
if (quantity <= 0)
|
||||||
{
|
{
|
||||||
TempData["ScanError"] = "Quantity must be greater than zero.";
|
TempData["ScanError"] = "Quantity must be greater than zero.";
|
||||||
return RedirectToAction(nameof(Scan), new { id = inventoryItemId });
|
return RedirectToAction(nameof(Scan), new { id = inventoryItemId });
|
||||||
}
|
}
|
||||||
|
|
||||||
var userId = _userManager.GetUserId(User) ?? string.Empty;
|
var result = await RecordInventoryUsageAsync(
|
||||||
// Scan-based logging always records as JobUsage; Adjustment is for manual stock corrections only
|
inventoryItemId, jobId, quantity,
|
||||||
var txnType = InventoryTransactionType.JobUsage;
|
InventoryTransactionType.JobUsage, notes);
|
||||||
|
|
||||||
item.QuantityOnHand -= quantity;
|
if (!result.Success)
|
||||||
item.UpdatedAt = DateTime.UtcNow;
|
|
||||||
await _unitOfWork.InventoryItems.UpdateAsync(item);
|
|
||||||
|
|
||||||
var txn = new InventoryTransaction
|
|
||||||
{
|
{
|
||||||
InventoryItemId = item.Id,
|
TempData["ScanError"] = result.Message;
|
||||||
TransactionType = txnType,
|
return RedirectToAction(nameof(Scan), new { id = inventoryItemId });
|
||||||
Quantity = -quantity,
|
|
||||||
UnitCost = item.UnitCost,
|
|
||||||
TotalCost = quantity * item.UnitCost,
|
|
||||||
TransactionDate = DateTime.UtcNow,
|
|
||||||
BalanceAfter = item.QuantityOnHand,
|
|
||||||
JobId = jobId,
|
|
||||||
Reference = jobId.HasValue ? $"Job #{jobId}" : null,
|
|
||||||
Notes = notes?.Trim()
|
|
||||||
};
|
|
||||||
await _unitOfWork.InventoryTransactions.AddAsync(txn);
|
|
||||||
await _unitOfWork.SaveChangesAsync();
|
|
||||||
|
|
||||||
// GL: DR COGS, CR Inventory Asset — no-op if accounts not configured on the item
|
|
||||||
if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue)
|
|
||||||
{
|
|
||||||
var cost = quantity * (item.AverageCost > 0 ? item.AverageCost : item.UnitCost);
|
|
||||||
await _accountBalanceService.DebitAsync(item.CogsAccountId, cost);
|
|
||||||
await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PowderUsageLog requires a specific JobItem + Coat FK — scan-based logging
|
TempData["ScanSuccess"] = result.Message;
|
||||||
// doesn't have that context, so we rely on the InventoryTransaction alone
|
|
||||||
// for the audit trail. Coat-level PowderUsageLogs are created by the job workflow.
|
|
||||||
|
|
||||||
TempData["ScanSuccess"] = $"Logged {quantity:N2} {item.UnitOfMeasure} of {item.Name}. New balance: {item.QuantityOnHand:N2} {item.UnitOfMeasure}.";
|
|
||||||
TempData["ScanItemId"] = inventoryItemId.ToString();
|
TempData["ScanItemId"] = inventoryItemId.ToString();
|
||||||
TempData["ScanJobId"] = jobId?.ToString();
|
TempData["ScanJobId"] = jobId?.ToString();
|
||||||
TempData["ScanItemName"] = item.Name;
|
TempData["ScanItemName"] = result.ItemName;
|
||||||
return RedirectToAction(nameof(ScanSuccess));
|
return RedirectToAction(nameof(ScanSuccess));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -1756,6 +1784,43 @@ public class InventoryController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records manual material usage from the job details modal. Accepts JSON, resolves
|
||||||
|
/// the amount used (caller sends the already-computed used quantity), and returns JSON
|
||||||
|
/// so the modal can close and refresh inline.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> LogMaterial([FromBody] LogMaterialRequest req)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (req.QuantityUsed <= 0)
|
||||||
|
return Json(new { success = false, message = "Quantity used must be greater than zero." });
|
||||||
|
|
||||||
|
var txnType = req.TransactionType == "Waste"
|
||||||
|
? InventoryTransactionType.Waste
|
||||||
|
: InventoryTransactionType.JobUsage;
|
||||||
|
|
||||||
|
var result = await RecordInventoryUsageAsync(
|
||||||
|
req.InventoryItemId, req.JobId, req.QuantityUsed, txnType, req.Notes);
|
||||||
|
|
||||||
|
return Json(new
|
||||||
|
{
|
||||||
|
success = result.Success,
|
||||||
|
message = result.Message,
|
||||||
|
newBalance = result.NewBalance,
|
||||||
|
unitOfMeasure = result.UnitOfMeasure,
|
||||||
|
itemName = result.ItemName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error logging material for job {JobId}", req.JobId);
|
||||||
|
return Json(new { success = false, message = "An error occurred. Please try again." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Success screen shown after a usage log is saved. Offers "Log Another Item for
|
/// Success screen shown after a usage log is saved. Offers "Log Another Item for
|
||||||
/// This Job" and "Done" options.
|
/// This Job" and "Done" options.
|
||||||
@@ -2003,7 +2068,7 @@ public class InventoryController : Controller
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the current values of a JobUsage InventoryTransaction plus a list of active
|
/// Returns the current values of a JobUsage InventoryTransaction plus a list of active
|
||||||
/// jobs so the edit modal can be pre-populated without a full page reload.
|
/// jobs (plus the currently assigned job even if terminal) for the edit modal.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> GetUsageForEdit(int id)
|
public async Task<IActionResult> GetUsageForEdit(int id)
|
||||||
@@ -2034,10 +2099,27 @@ public class InventoryController : Controller
|
|||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
// If the assigned job has terminal status it won't appear in the active list; insert it at the top
|
||||||
|
// so the dropdown pre-selects correctly and the user can see the existing job assignment.
|
||||||
|
if (txn.JobId.HasValue && jobs.All(j => j.Id != txn.JobId.Value))
|
||||||
|
{
|
||||||
|
var assignedJob = await _unitOfWork.Jobs.GetByIdAsync(txn.JobId.Value, false, j => j.Customer);
|
||||||
|
if (assignedJob != null)
|
||||||
|
jobs.Insert(0, new ScanJobOption
|
||||||
|
{
|
||||||
|
Id = assignedJob.Id,
|
||||||
|
JobNumber = assignedJob.JobNumber,
|
||||||
|
CustomerName = assignedJob.Customer != null
|
||||||
|
? (assignedJob.Customer.CompanyName ?? $"{assignedJob.Customer.ContactFirstName} {assignedJob.Customer.ContactLastName}".Trim())
|
||||||
|
: "No Customer"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return Json(new
|
return Json(new
|
||||||
{
|
{
|
||||||
transactionId = txn.Id,
|
transactionId = txn.Id,
|
||||||
jobId = txn.JobId,
|
jobId = txn.JobId,
|
||||||
|
quantity = Math.Abs(txn.Quantity),
|
||||||
notes = txn.Notes,
|
notes = txn.Notes,
|
||||||
transactionDate = txn.TransactionDate.ToString("yyyy-MM-ddTHH:mm"),
|
transactionDate = txn.TransactionDate.ToString("yyyy-MM-ddTHH:mm"),
|
||||||
itemName = txn.InventoryItem?.Name,
|
itemName = txn.InventoryItem?.Name,
|
||||||
@@ -2046,14 +2128,15 @@ public class InventoryController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Saves edits to a JobUsage InventoryTransaction's job assignment, notes, and date.
|
/// Saves edits to a JobUsage InventoryTransaction: job assignment, quantity, notes, and date.
|
||||||
/// Quantity and balance are not changed.
|
/// When quantity changes the InventoryItem.QuantityOnHand is adjusted by the delta so the
|
||||||
|
/// ledger balance remains consistent.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> EditUsageTransaction(int id, int? jobId, string? notes, DateTime transactionDate)
|
public async Task<IActionResult> EditUsageTransaction(int id, int? jobId, string? notes, DateTime transactionDate, decimal? quantity)
|
||||||
{
|
{
|
||||||
var txn = await _unitOfWork.InventoryTransactions.GetByIdAsync(id);
|
var txn = await _unitOfWork.InventoryTransactions.GetByIdAsync(id, false, t => t.InventoryItem);
|
||||||
if (txn == null) return NotFound();
|
if (txn == null) return NotFound();
|
||||||
if (txn.TransactionType != InventoryTransactionType.JobUsage
|
if (txn.TransactionType != InventoryTransactionType.JobUsage
|
||||||
&& txn.TransactionType != InventoryTransactionType.Adjustment)
|
&& txn.TransactionType != InventoryTransactionType.Adjustment)
|
||||||
@@ -2075,6 +2158,28 @@ public class InventoryController : Controller
|
|||||||
if (jobId.HasValue && txn.TransactionType == InventoryTransactionType.Adjustment)
|
if (jobId.HasValue && txn.TransactionType == InventoryTransactionType.Adjustment)
|
||||||
txn.TransactionType = InventoryTransactionType.JobUsage;
|
txn.TransactionType = InventoryTransactionType.JobUsage;
|
||||||
|
|
||||||
|
// Adjust inventory when the logged quantity is changed.
|
||||||
|
// txn.Quantity is stored as a negative number for usage (e.g. -3.5 for 3.5 lbs used).
|
||||||
|
if (quantity.HasValue && quantity.Value > 0)
|
||||||
|
{
|
||||||
|
var oldUsed = Math.Abs(txn.Quantity);
|
||||||
|
var newUsed = quantity.Value;
|
||||||
|
if (oldUsed != newUsed)
|
||||||
|
{
|
||||||
|
var item = txn.InventoryItem ?? await _unitOfWork.InventoryItems.GetByIdAsync(txn.InventoryItemId);
|
||||||
|
if (item != null)
|
||||||
|
{
|
||||||
|
// Positive delta means less was actually used → restore the difference to inventory.
|
||||||
|
item.QuantityOnHand += oldUsed - newUsed;
|
||||||
|
item.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _unitOfWork.InventoryItems.UpdateAsync(item);
|
||||||
|
txn.BalanceAfter = item.QuantityOnHand;
|
||||||
|
}
|
||||||
|
txn.Quantity = -newUsed;
|
||||||
|
txn.TotalCost = newUsed * txn.UnitCost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
txn.Notes = notes?.Trim();
|
txn.Notes = notes?.Trim();
|
||||||
txn.TransactionDate = transactionDate.Kind == DateTimeKind.Utc
|
txn.TransactionDate = transactionDate.Kind == DateTimeKind.Utc
|
||||||
? transactionDate : DateTime.SpecifyKind(transactionDate, DateTimeKind.Utc);
|
? transactionDate : DateTime.SpecifyKind(transactionDate, DateTimeKind.Utc);
|
||||||
@@ -2094,3 +2199,21 @@ public class ScanJobOption
|
|||||||
public string JobNumber { get; set; } = string.Empty;
|
public string JobNumber { get; set; } = string.Empty;
|
||||||
public string CustomerName { get; set; } = string.Empty;
|
public string CustomerName { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Result returned by RecordInventoryUsageAsync.</summary>
|
||||||
|
public record InventoryUsageResult(
|
||||||
|
bool Success,
|
||||||
|
string Message,
|
||||||
|
decimal NewBalance,
|
||||||
|
string UnitOfMeasure,
|
||||||
|
string ItemName);
|
||||||
|
|
||||||
|
/// <summary>JSON body for the LogMaterial endpoint (job details modal).</summary>
|
||||||
|
public class LogMaterialRequest
|
||||||
|
{
|
||||||
|
public int JobId { get; set; }
|
||||||
|
public int InventoryItemId { get; set; }
|
||||||
|
public decimal QuantityUsed { get; set; }
|
||||||
|
public string TransactionType { get; set; } = "JobUsage";
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -372,6 +372,7 @@ public class InvoicesController : Controller
|
|||||||
dto.JobId = job.Id;
|
dto.JobId = job.Id;
|
||||||
dto.CustomerId = job.CustomerId;
|
dto.CustomerId = job.CustomerId;
|
||||||
dto.CustomerPO = job.CustomerPO;
|
dto.CustomerPO = job.CustomerPO;
|
||||||
|
dto.ProjectName = job.ProjectName;
|
||||||
|
|
||||||
// Resolve catalog item revenue accounts for pre-population
|
// Resolve catalog item revenue accounts for pre-population
|
||||||
var catalogItemIds = job.JobItems
|
var catalogItemIds = job.JobItems
|
||||||
@@ -710,6 +711,7 @@ public class InvoicesController : Controller
|
|||||||
InternalNotes = dto.InternalNotes,
|
InternalNotes = dto.InternalNotes,
|
||||||
Terms = dto.Terms,
|
Terms = dto.Terms,
|
||||||
CustomerPO = dto.CustomerPO,
|
CustomerPO = dto.CustomerPO,
|
||||||
|
ProjectName = dto.ProjectName,
|
||||||
CompanyId = currentUser.CompanyId,
|
CompanyId = currentUser.CompanyId,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
CreatedBy = currentUser.Email
|
CreatedBy = currentUser.Email
|
||||||
@@ -901,6 +903,7 @@ public class InvoicesController : Controller
|
|||||||
InternalNotes = invoice.InternalNotes,
|
InternalNotes = invoice.InternalNotes,
|
||||||
Terms = invoice.Terms,
|
Terms = invoice.Terms,
|
||||||
CustomerPO = invoice.CustomerPO,
|
CustomerPO = invoice.CustomerPO,
|
||||||
|
ProjectName = invoice.ProjectName ?? invoice.Job?.ProjectName,
|
||||||
InvoiceItems = invoice.InvoiceItems
|
InvoiceItems = invoice.InvoiceItems
|
||||||
.Where(i => !i.IsDeleted)
|
.Where(i => !i.IsDeleted)
|
||||||
.OrderBy(i => i.DisplayOrder)
|
.OrderBy(i => i.DisplayOrder)
|
||||||
@@ -1036,6 +1039,7 @@ public class InvoicesController : Controller
|
|||||||
invoice.InternalNotes = dto.InternalNotes;
|
invoice.InternalNotes = dto.InternalNotes;
|
||||||
invoice.Terms = dto.Terms;
|
invoice.Terms = dto.Terms;
|
||||||
invoice.CustomerPO = dto.CustomerPO;
|
invoice.CustomerPO = dto.CustomerPO;
|
||||||
|
invoice.ProjectName = dto.ProjectName;
|
||||||
invoice.UpdatedAt = DateTime.UtcNow;
|
invoice.UpdatedAt = DateTime.UtcNow;
|
||||||
invoice.UpdatedBy = currentUser?.Email;
|
invoice.UpdatedBy = currentUser?.Email;
|
||||||
|
|
||||||
|
|||||||
@@ -4399,75 +4399,7 @@ public class JobsController : Controller
|
|||||||
_logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId);
|
_logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
// LogMaterial has been consolidated into InventoryController.LogMaterial.
|
||||||
/// Logs manual material usage from the job details page. Mirrors the QR scan LogUsage
|
|
||||||
/// flow in InventoryController but returns JSON so the modal can close and refresh inline.
|
|
||||||
/// Quantity is always the amount USED (caller converts from remaining if needed).
|
|
||||||
/// </summary>
|
|
||||||
[HttpPost]
|
|
||||||
[ValidateAntiForgeryToken]
|
|
||||||
public async Task<IActionResult> LogMaterial([FromBody] LogMaterialRequest req)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (req.QuantityUsed <= 0)
|
|
||||||
return Json(new { success = false, message = "Quantity used must be greater than zero." });
|
|
||||||
|
|
||||||
var item = await _unitOfWork.InventoryItems.GetByIdAsync(req.InventoryItemId);
|
|
||||||
if (item == null) return Json(new { success = false, message = "Inventory item not found." });
|
|
||||||
|
|
||||||
var job = await _unitOfWork.Jobs.GetByIdAsync(req.JobId);
|
|
||||||
if (job == null) return Json(new { success = false, message = "Job not found." });
|
|
||||||
|
|
||||||
var txnType = req.TransactionType == "Waste"
|
|
||||||
? InventoryTransactionType.Waste
|
|
||||||
: InventoryTransactionType.JobUsage;
|
|
||||||
|
|
||||||
item.QuantityOnHand -= req.QuantityUsed;
|
|
||||||
item.UpdatedAt = DateTime.UtcNow;
|
|
||||||
await _unitOfWork.InventoryItems.UpdateAsync(item);
|
|
||||||
|
|
||||||
var txn = new PowderCoating.Core.Entities.InventoryTransaction
|
|
||||||
{
|
|
||||||
InventoryItemId = item.Id,
|
|
||||||
TransactionType = txnType,
|
|
||||||
Quantity = -req.QuantityUsed,
|
|
||||||
UnitCost = item.UnitCost,
|
|
||||||
TotalCost = req.QuantityUsed * item.UnitCost,
|
|
||||||
TransactionDate = DateTime.UtcNow,
|
|
||||||
BalanceAfter = item.QuantityOnHand,
|
|
||||||
JobId = req.JobId,
|
|
||||||
Reference = $"Job {job.JobNumber}",
|
|
||||||
Notes = req.Notes?.Trim(),
|
|
||||||
CompanyId = item.CompanyId,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
await _unitOfWork.InventoryTransactions.AddAsync(txn);
|
|
||||||
await _unitOfWork.CompleteAsync();
|
|
||||||
|
|
||||||
// GL: DR COGS, CR Inventory Asset
|
|
||||||
if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue)
|
|
||||||
{
|
|
||||||
var cost = req.QuantityUsed * (item.AverageCost > 0 ? item.AverageCost : item.UnitCost);
|
|
||||||
await _accountBalanceService.DebitAsync(item.CogsAccountId, cost);
|
|
||||||
await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Json(new
|
|
||||||
{
|
|
||||||
success = true,
|
|
||||||
message = $"Logged {req.QuantityUsed:N2} {item.UnitOfMeasure} of {item.Name}.",
|
|
||||||
newBalance = item.QuantityOnHand,
|
|
||||||
unitOfMeasure = item.UnitOfMeasure,
|
|
||||||
itemName = item.Name
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error logging material for job {JobId}", req.JobId);
|
|
||||||
return Json(new { success = false, message = "An error occurred. Please try again." });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Inline-edits description, quantity, and unit price on a single job line item.
|
/// Inline-edits description, quantity, and unit price on a single job line item.
|
||||||
@@ -4554,14 +4486,6 @@ public class PatchJobItemRequest
|
|||||||
public decimal Quantity { get; set; }
|
public decimal Quantity { get; set; }
|
||||||
public decimal UnitPrice { get; set; }
|
public decimal UnitPrice { get; set; }
|
||||||
}
|
}
|
||||||
public class LogMaterialRequest
|
|
||||||
{
|
|
||||||
public int JobId { get; set; }
|
|
||||||
public int InventoryItemId { get; set; }
|
|
||||||
public decimal QuantityUsed { get; set; }
|
|
||||||
public string TransactionType { get; set; } = "JobUsage";
|
|
||||||
public string? Notes { get; set; }
|
|
||||||
}
|
|
||||||
public class CreateReworkJobRequest
|
public class CreateReworkJobRequest
|
||||||
{
|
{
|
||||||
public int ReworkRecordId { get; set; }
|
public int ReworkRecordId { get; set; }
|
||||||
|
|||||||
@@ -1957,12 +1957,10 @@ public class QuotesController : Controller
|
|||||||
if (dto.SmsConsent)
|
if (dto.SmsConsent)
|
||||||
await _notificationService.NotifySmsConsentGrantedAsync(customer);
|
await _notificationService.NotifySmsConsentGrantedAsync(customer);
|
||||||
|
|
||||||
// Get "Converted" status (cached)
|
// Update quote to link to new customer.
|
||||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
// Do NOT set "Converted" status here — that status is reserved for when a job is
|
||||||
var statuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId);
|
// actually created via CreateJobFromQuote. Keeping the quote at "Approved" lets the
|
||||||
var convertedStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Converted);
|
// user immediately click "Create Job from Quote" on the next screen.
|
||||||
|
|
||||||
// Update quote to link to new customer
|
|
||||||
quote.CustomerId = customer.Id;
|
quote.CustomerId = customer.Id;
|
||||||
|
|
||||||
// Clear prospect fields
|
// Clear prospect fields
|
||||||
@@ -1977,14 +1975,11 @@ public class QuotesController : Controller
|
|||||||
quote.ProspectSmsConsent = false;
|
quote.ProspectSmsConsent = false;
|
||||||
quote.ProspectSmsConsentedAt = null;
|
quote.ProspectSmsConsentedAt = null;
|
||||||
|
|
||||||
// Update status to converted
|
|
||||||
quote.QuoteStatusId = convertedStatus?.Id ?? quote.QuoteStatusId;
|
|
||||||
|
|
||||||
await _unitOfWork.Quotes.UpdateAsync(quote);
|
await _unitOfWork.Quotes.UpdateAsync(quote);
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
this.ToastSuccess($"Prospect/Walk-In successfully converted to customer! Quote {quote.QuoteNumber} has been updated.");
|
this.ToastSuccess($"Customer record created! You can now create a job from quote {quote.QuoteNumber}.");
|
||||||
return RedirectToAction("Details", "Customers", new { id = customer.Id });
|
return RedirectToAction(nameof(Details), new { id = dto.QuoteId });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -2958,6 +2953,7 @@ public class QuotesController : Controller
|
|||||||
Total = quote.Total
|
Total = quote.Total
|
||||||
}),
|
}),
|
||||||
CustomerPO = quote.CustomerPO,
|
CustomerPO = quote.CustomerPO,
|
||||||
|
ProjectName = quote.ProjectName,
|
||||||
InternalNotes = quote.Notes, // Copy internal notes from quote
|
InternalNotes = quote.Notes, // Copy internal notes from quote
|
||||||
IsCustomerApproved = true,
|
IsCustomerApproved = true,
|
||||||
IsRushJob = quote.IsRushJob,
|
IsRushJob = quote.IsRushJob,
|
||||||
@@ -3435,13 +3431,21 @@ public class QuotesController : Controller
|
|||||||
// Build company AI context: profile text + recent accepted predictions as few-shot examples
|
// Build company AI context: profile text + recent accepted predictions as few-shot examples
|
||||||
var aiContext = await BuildCompanyAiContextAsync(companyId, costs);
|
var aiContext = await BuildCompanyAiContextAsync(companyId, costs);
|
||||||
|
|
||||||
// Load the specific blast setup when the user picked one before analyzing
|
// Load the specific blast setup when the user picked one before analyzing.
|
||||||
|
// If none was explicitly chosen, fall back to the company's default blast setup so
|
||||||
|
// named-setup rates (e.g. a blast cabinet configured at 82 sqft/hr) are always
|
||||||
|
// used instead of the coarser company-level operating cost fallback.
|
||||||
CompanyBlastSetup? selectedBlastSetup = null;
|
CompanyBlastSetup? selectedBlastSetup = null;
|
||||||
if (request.BlastSetupId.HasValue)
|
if (request.BlastSetupId.HasValue)
|
||||||
{
|
{
|
||||||
var setups = await _unitOfWork.BlastSetups.FindAsync(b => b.Id == request.BlastSetupId.Value && b.IsActive && b.CompanyId == companyId);
|
var setups = await _unitOfWork.BlastSetups.FindAsync(b => b.Id == request.BlastSetupId.Value && b.IsActive && b.CompanyId == companyId);
|
||||||
selectedBlastSetup = setups.FirstOrDefault();
|
selectedBlastSetup = setups.FirstOrDefault();
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var defaultSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsDefault && b.IsActive && b.CompanyId == companyId);
|
||||||
|
selectedBlastSetup = defaultSetups.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
var result = await _aiService.AnalyzeItemAsync(request, photos, costs, avgPowderCost, aiContext, selectedBlastSetup);
|
var result = await _aiService.AnalyzeItemAsync(request, photos, costs, avgPowderCost, aiContext, selectedBlastSetup);
|
||||||
await _usageLogger.LogAsync(companyId, user?.Id ?? "", AppConstants.AiFeatures.PhotoQuote, result.Success, photos.Sum(p => p.Data.Length));
|
await _usageLogger.LogAsync(companyId, user?.Id ?? "", AppConstants.AiFeatures.PhotoQuote, result.Success, photos.Sum(p => p.Data.Length));
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label asp-for="Email" class="form-label">Email <span class="text-danger">*</span> <span class="text-muted fw-normal">(required if no phone number)</span></label>
|
<label asp-for="Email" class="form-label">Email <span class="text-danger">*</span> <span class="text-muted fw-normal">(required if no phone number)</span></label>
|
||||||
<input asp-for="Email" type="email" multiple class="form-control" placeholder="name@example.com (comma-separate multiple)" />
|
<input asp-for="Email" type="text" class="form-control" placeholder="name@example.com (comma-separate multiple)" />
|
||||||
<span asp-validation-for="Email" class="text-danger"></span>
|
<span asp-validation-for="Email" class="text-danger"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
<label asp-for="BillingEmail" class="form-label">Billing / Accounting Email
|
<label asp-for="BillingEmail" class="form-label">Billing / Accounting Email
|
||||||
<span class="text-muted fw-normal">(invoices sent here)</span>
|
<span class="text-muted fw-normal">(invoices sent here)</span>
|
||||||
</label>
|
</label>
|
||||||
<input asp-for="BillingEmail" type="email" multiple class="form-control" placeholder="accounting@company.com (comma-separate multiple)" />
|
<input asp-for="BillingEmail" type="text" class="form-control" placeholder="accounting@company.com (comma-separate multiple)" />
|
||||||
<span asp-validation-for="BillingEmail" class="text-danger"></span>
|
<span asp-validation-for="BillingEmail" class="text-danger"></span>
|
||||||
<div class="form-text">When set, invoices are emailed here instead of the contact email.</div>
|
<div class="form-text">When set, invoices are emailed here instead of the contact email.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label asp-for="Email" class="form-label">Email</label>
|
<label asp-for="Email" class="form-label">Email</label>
|
||||||
<input asp-for="Email" type="email" multiple class="form-control" placeholder="name@example.com (comma-separate multiple)" />
|
<input asp-for="Email" type="text" class="form-control" placeholder="name@example.com (comma-separate multiple)" />
|
||||||
<span asp-validation-for="Email" class="text-danger"></span>
|
<span asp-validation-for="Email" class="text-danger"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
<label asp-for="BillingEmail" class="form-label">Billing / Accounting Email
|
<label asp-for="BillingEmail" class="form-label">Billing / Accounting Email
|
||||||
<span class="text-muted fw-normal">(invoices sent here)</span>
|
<span class="text-muted fw-normal">(invoices sent here)</span>
|
||||||
</label>
|
</label>
|
||||||
<input asp-for="BillingEmail" type="email" multiple class="form-control" placeholder="accounting@company.com (comma-separate multiple)" />
|
<input asp-for="BillingEmail" type="text" class="form-control" placeholder="accounting@company.com (comma-separate multiple)" />
|
||||||
<span asp-validation-for="BillingEmail" class="text-danger"></span>
|
<span asp-validation-for="BillingEmail" class="text-danger"></span>
|
||||||
<div class="form-text">When set, invoices are emailed here instead of the contact email.</div>
|
<div class="form-text">When set, invoices are emailed here instead of the contact email.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,19 +44,30 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<div class="card border-0 shadow-sm">
|
<a asp-action="Index" asp-route-lowStockOnly="true" class="text-decoration-none"
|
||||||
<div class="card-body">
|
title="Click to filter list to low stock items">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
@{ var _lowStockActive = (bool)(ViewBag.LowStockOnly ?? false); }
|
||||||
<div>
|
<div class="card border-0 shadow-sm @(_lowStockActive ? "border-danger border" : "")"
|
||||||
<p class="text-muted mb-1" style="font-size: 0.875rem;">Low Stock Items</p>
|
style="cursor:pointer;transition:box-shadow .15s;">
|
||||||
<h3 class="mb-0 fw-bold @(lowStockCount > 0 ? "text-danger" : "")">@lowStockCount</h3>
|
<div class="card-body">
|
||||||
</div>
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<div class="rounded-circle p-3" style="background: #fee2e2;">
|
<div>
|
||||||
<i class="bi bi-exclamation-triangle text-danger" style="font-size: 1.5rem;"></i>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
@@ -102,11 +113,13 @@
|
|||||||
<div class="stat-value">@Model.TotalCount</div>
|
<div class="stat-value">@Model.TotalCount</div>
|
||||||
<div class="stat-label">Total</div>
|
<div class="stat-label">Total</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item">
|
<a asp-action="Index" asp-route-lowStockOnly="true" class="text-decoration-none">
|
||||||
|
<div class="stat-item" style="cursor:pointer;">
|
||||||
<div class="stat-icon"><i class="bi bi-exclamation-triangle text-danger"></i></div>
|
<div class="stat-icon"><i class="bi bi-exclamation-triangle text-danger"></i></div>
|
||||||
<div class="stat-value @(lowStockCount > 0 ? "text-danger" : "")">@lowStockCount</div>
|
<div class="stat-value @(lowStockCount > 0 ? "text-danger" : "")">@lowStockCount</div>
|
||||||
<div class="stat-label">Low Stock</div>
|
<div class="stat-label">Low Stock</div>
|
||||||
</div>
|
</div>
|
||||||
|
</a>
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<div class="stat-icon"><i class="bi bi-check-circle text-success"></i></div>
|
<div class="stat-icon"><i class="bi bi-check-circle text-success"></i></div>
|
||||||
<div class="stat-value">@activeCount</div>
|
<div class="stat-value">@activeCount</div>
|
||||||
|
|||||||
@@ -353,6 +353,11 @@
|
|||||||
<label class="form-label fw-semibold">Powder Item</label>
|
<label class="form-label fw-semibold">Powder Item</label>
|
||||||
<p id="euItemName" class="form-control-plaintext text-muted"></p>
|
<p id="euItemName" class="form-control-plaintext text-muted"></p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="euQuantity" class="form-label fw-semibold">Amount Used <small class="text-muted fw-normal" id="euQuantityUom"></small></label>
|
||||||
|
<input type="number" id="euQuantity" name="quantity" class="form-control" min="0.001" step="any" required />
|
||||||
|
<div class="form-text">Adjusts the inventory balance by the difference from the original entry.</div>
|
||||||
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="euJobId" class="form-label fw-semibold">Job <span class="text-muted fw-normal">(optional)</span></label>
|
<label for="euJobId" class="form-label fw-semibold">Job <span class="text-muted fw-normal">(optional)</span></label>
|
||||||
<select id="euJobId" name="jobId" class="form-select">
|
<select id="euJobId" name="jobId" class="form-select">
|
||||||
|
|||||||
@@ -170,6 +170,12 @@
|
|||||||
<input asp-for="CustomerPO" class="form-control" placeholder="Optional" />
|
<input asp-for="CustomerPO" class="form-control" placeholder="Optional" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row g-3 mt-1">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label asp-for="ProjectName" class="form-label fw-semibold mb-0">Project Name</label>
|
||||||
|
<input asp-for="ProjectName" class="form-control" placeholder="Optional — prints on invoice" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="row g-3 mt-1">
|
<div class="row g-3 mt-1">
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
<div class="d-flex align-items-center gap-1">
|
<div class="d-flex align-items-center gap-1">
|
||||||
|
|||||||
@@ -193,6 +193,13 @@
|
|||||||
<p class="mb-0">@Model.CustomerPO</p>
|
<p class="mb-0">@Model.CustomerPO</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@if (!string.IsNullOrWhiteSpace(Model.ProjectName))
|
||||||
|
{
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="text-muted small mb-1">Project Name</label>
|
||||||
|
<p class="mb-0">@Model.ProjectName</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@if (!string.IsNullOrWhiteSpace(Model.ExternalReference))
|
@if (!string.IsNullOrWhiteSpace(Model.ExternalReference))
|
||||||
{
|
{
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
|
|||||||
@@ -62,6 +62,12 @@
|
|||||||
<input asp-for="CustomerPO" class="form-control" placeholder="Optional" />
|
<input asp-for="CustomerPO" class="form-control" placeholder="Optional" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row g-3 mt-1">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label asp-for="ProjectName" class="form-label fw-semibold">Project Name</label>
|
||||||
|
<input asp-for="ProjectName" class="form-control" placeholder="Optional — prints on invoice" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="row g-3 mt-1">
|
<div class="row g-3 mt-1">
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
<label asp-for="Terms" class="form-label fw-semibold">Payment Terms</label>
|
<label asp-for="Terms" class="form-label fw-semibold">Payment Terms</label>
|
||||||
|
|||||||
@@ -124,6 +124,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<input asp-for="CustomerPO" class="form-control" placeholder="Enter PO number" />
|
<input asp-for="CustomerPO" class="form-control" placeholder="Enter PO number" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label asp-for="ProjectName" class="form-label">Project Name</label>
|
||||||
|
<input asp-for="ProjectName" class="form-control" placeholder="e.g. Kitchen Remodel, Fleet Vehicle #3…" />
|
||||||
|
</div>
|
||||||
<div class="col-md-7">
|
<div class="col-md-7">
|
||||||
<div class="d-flex align-items-center gap-1">
|
<div class="d-flex align-items-center gap-1">
|
||||||
<label asp-for="SpecialInstructions" class="form-label mb-0">Special Instructions</label>
|
<label asp-for="SpecialInstructions" class="form-label mb-0">Special Instructions</label>
|
||||||
|
|||||||
@@ -172,6 +172,13 @@
|
|||||||
<label class="text-muted small mb-1">Customer PO</label>
|
<label class="text-muted small mb-1">Customer PO</label>
|
||||||
<p class="mb-0">@(Model.CustomerPO ?? "Not provided")</p>
|
<p class="mb-0">@(Model.CustomerPO ?? "Not provided")</p>
|
||||||
</div>
|
</div>
|
||||||
|
@if (!string.IsNullOrEmpty(Model.ProjectName))
|
||||||
|
{
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="text-muted small mb-1">Project</label>
|
||||||
|
<p class="mb-0">@Model.ProjectName</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label class="text-muted small mb-1">Description</label>
|
<label class="text-muted small mb-1">Description</label>
|
||||||
<p class="mb-0">@Model.Description</p>
|
<p class="mb-0">@Model.Description</p>
|
||||||
@@ -1158,21 +1165,24 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label fw-semibold">Entry Method</label>
|
<label class="form-label fw-semibold">Entry Method</label>
|
||||||
<div class="d-flex gap-3">
|
<div class="btn-group w-100" role="group">
|
||||||
<div class="form-check">
|
<button type="button" id="lmBtnUsed" class="btn btn-primary"
|
||||||
<input class="form-check-input" type="radio" name="lmEntryMethod" id="lmMethodUsed" value="used" checked onchange="lmUpdateQuantityLabel()">
|
onclick="lmSetMethod('used')">
|
||||||
<label class="form-check-label" for="lmMethodUsed">Amount Used</label>
|
<i class="bi bi-droplet me-1"></i>Amount Used
|
||||||
</div>
|
</button>
|
||||||
<div class="form-check">
|
<button type="button" id="lmBtnRemaining" class="btn btn-outline-primary"
|
||||||
<input class="form-check-input" type="radio" name="lmEntryMethod" id="lmMethodRemaining" value="remaining" onchange="lmUpdateQuantityLabel()">
|
onclick="lmSetMethod('remaining')">
|
||||||
<label class="form-check-label" for="lmMethodRemaining">Amount Remaining</label>
|
<i class="bi bi-droplet-half me-1"></i>Amount Remaining
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">
|
||||||
|
<span id="lmMethodHint">Enter how much powder you took out of the bag.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label id="lmQtyLabel" class="form-label fw-semibold">Quantity Used <span class="text-danger">*</span></label>
|
<label id="lmQtyLabel" class="form-label fw-semibold">Quantity Used <span class="text-danger">*</span></label>
|
||||||
<input type="number" id="lmQuantity" class="form-control" min="0" step="0.01" placeholder="0.00">
|
<input type="number" id="lmQuantity" class="form-control" min="0" step="0.01" placeholder="0.00">
|
||||||
<div id="lmComputedUsed" class="form-text text-muted d-none"></div>
|
<div id="lmComputedUsed" class="form-text fw-semibold d-none"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label fw-semibold">Reason</label>
|
<label class="form-label fw-semibold">Reason</label>
|
||||||
@@ -3311,7 +3321,7 @@
|
|||||||
const inventoryItems = @Html.Raw(ViewBag.InventoryItemsForModal ?? "[]");
|
const inventoryItems = @Html.Raw(ViewBag.InventoryItemsForModal ?? "[]");
|
||||||
const jobPowderIds = @Html.Raw(ViewBag.JobPowderIds ?? "[]");
|
const jobPowderIds = @Html.Raw(ViewBag.JobPowderIds ?? "[]");
|
||||||
const jobId = @Model.Id;
|
const jobId = @Model.Id;
|
||||||
const logUrl = '@Url.Action("LogMaterial", "Jobs")';
|
const logUrl = '@Url.Action("LogMaterial", "Inventory")';
|
||||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||||
window.__logMaterial = { inventoryItems, jobPowderIds, jobId, logUrl, token };
|
window.__logMaterial = { inventoryItems, jobPowderIds, jobId, logUrl, token };
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -101,6 +101,10 @@
|
|||||||
<label asp-for="CustomerPO" class="form-label">Customer PO</label>
|
<label asp-for="CustomerPO" class="form-label">Customer PO</label>
|
||||||
<input asp-for="CustomerPO" class="form-control" placeholder="Enter PO number" />
|
<input asp-for="CustomerPO" class="form-control" placeholder="Enter PO number" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label asp-for="ProjectName" class="form-label">Project Name</label>
|
||||||
|
<input asp-for="ProjectName" class="form-control" placeholder="e.g. Kitchen Remodel, Fleet Vehicle #3…" />
|
||||||
|
</div>
|
||||||
<div class="col-md-7">
|
<div class="col-md-7">
|
||||||
<label asp-for="SpecialInstructions" class="form-label">Special Instructions</label>
|
<label asp-for="SpecialInstructions" class="form-label">Special Instructions</label>
|
||||||
<textarea asp-for="SpecialInstructions" class="form-control" rows="3" placeholder="Any special instructions"></textarea>
|
<textarea asp-for="SpecialInstructions" class="form-control" rows="3" placeholder="Any special instructions"></textarea>
|
||||||
|
|||||||
@@ -191,7 +191,14 @@
|
|||||||
var isHot = job.DueDate.HasValue && job.DueDate.Value < DateTime.Now
|
var isHot = job.DueDate.HasValue && job.DueDate.Value < DateTime.Now
|
||||||
&& job.StatusCode != "COMPLETED" && job.StatusCode != "READYFORPICKUP"
|
&& job.StatusCode != "COMPLETED" && job.StatusCode != "READYFORPICKUP"
|
||||||
&& job.StatusCode != "DELIVERED" && job.StatusCode != "CANCELLED";
|
&& job.StatusCode != "DELIVERED" && job.StatusCode != "CANCELLED";
|
||||||
<tr class="job-row" data-job-id="@job.Id" style="cursor: pointer;">
|
var tipParts = new List<string>();
|
||||||
|
if (!string.IsNullOrWhiteSpace(job.Description)) tipParts.Add(job.Description);
|
||||||
|
if (!string.IsNullOrWhiteSpace(job.CustomerPO)) tipParts.Add("PO: " + job.CustomerPO);
|
||||||
|
var tipText = string.Join(" · ", tipParts);
|
||||||
|
<tr class="job-row" data-job-id="@job.Id" style="cursor: pointer;"
|
||||||
|
@if (!string.IsNullOrEmpty(tipText)) {
|
||||||
|
<text>data-bs-toggle="tooltip" data-bs-placement="top" data-bs-title="@Html.Encode(tipText)"</text>
|
||||||
|
}>
|
||||||
<td class="ps-4 @(isHot ? "job-hot-cell" : "")">
|
<td class="ps-4 @(isHot ? "job-hot-cell" : "")">
|
||||||
<div>
|
<div>
|
||||||
<div class="mono fw-500">
|
<div class="mono fw-500">
|
||||||
@@ -629,6 +636,10 @@
|
|||||||
loadJobStatuses();
|
loadJobStatuses();
|
||||||
loadJobPriorities();
|
loadJobPriorities();
|
||||||
|
|
||||||
|
// Row tooltips (description + PO)
|
||||||
|
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el =>
|
||||||
|
new bootstrap.Tooltip(el, { trigger: 'hover' }));
|
||||||
|
|
||||||
// / key focuses search input
|
// / key focuses search input
|
||||||
document.addEventListener('keydown', function(e) {
|
document.addEventListener('keydown', function(e) {
|
||||||
if (e.key === '/' && document.activeElement.tagName !== 'INPUT' && document.activeElement.tagName !== 'TEXTAREA') {
|
if (e.key === '/' && document.activeElement.tagName !== 'INPUT' && document.activeElement.tagName !== 'TEXTAREA') {
|
||||||
|
|||||||
@@ -357,6 +357,13 @@
|
|||||||
<div class="info-value">@Model.CustomerPO</div>
|
<div class="info-value">@Model.CustomerPO</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@if (!string.IsNullOrWhiteSpace(Model.ProjectName))
|
||||||
|
{
|
||||||
|
<div class="info-row">
|
||||||
|
<div class="info-label">Project</div>
|
||||||
|
<div class="info-value">@Model.ProjectName</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
|
|||||||
@@ -187,6 +187,12 @@
|
|||||||
<input asp-for="CustomerPO" class="form-control" />
|
<input asp-for="CustomerPO" class="form-control" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row mt-2">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label asp-for="ProjectName" class="form-label"></label>
|
||||||
|
<input asp-for="ProjectName" class="form-control" placeholder="e.g. Kitchen Remodel, Fleet Vehicle #3…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="row mt-2">
|
<div class="row mt-2">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label asp-for="Notes" class="form-label"></label>
|
<label asp-for="Notes" class="form-label"></label>
|
||||||
|
|||||||
@@ -183,6 +183,10 @@
|
|||||||
{
|
{
|
||||||
<p><strong>Customer PO:</strong> @Model.CustomerPO</p>
|
<p><strong>Customer PO:</strong> @Model.CustomerPO</p>
|
||||||
}
|
}
|
||||||
|
@if (!string.IsNullOrEmpty(Model.ProjectName))
|
||||||
|
{
|
||||||
|
<p><strong>Project:</strong> @Model.ProjectName</p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (!string.IsNullOrEmpty(Model.Description))
|
@if (!string.IsNullOrEmpty(Model.Description))
|
||||||
|
|||||||
@@ -150,6 +150,12 @@
|
|||||||
<input asp-for="CustomerPO" class="form-control" />
|
<input asp-for="CustomerPO" class="form-control" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row mt-2">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label asp-for="ProjectName" class="form-label"></label>
|
||||||
|
<input asp-for="ProjectName" class="form-control" placeholder="e.g. Kitchen Remodel, Fleet Vehicle #3…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="row mt-2">
|
<div class="row mt-2">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label asp-for="Notes" class="form-label"></label>
|
<label asp-for="Notes" class="form-label"></label>
|
||||||
|
|||||||
@@ -1094,30 +1094,31 @@
|
|||||||
3: 'Powder Coat'
|
3: 'Powder Coat'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Nozzle multipliers matching ShopCapabilityCalculator
|
// No client-side blast-rate formula here — ShopCapabilityCalculator.cs is the single
|
||||||
const blastNozzleMultipliers = [0, 0, 0.35, 0.55, 0.75, 1.00, 1.30, 1.65, 2.00];
|
// source of truth. The table uses derivedRate from the server response; the modal
|
||||||
const blastSetupModalTypeMultipliers = { 0: 0.55, 1: 0.70, 2: 1.00, 3: 0.45 };
|
// live-preview calls /CompanySettings/DeriveBlastRate instead.
|
||||||
const blastSubstrateMultipliers = { 0: 1.00, 1: 0.80, 2: 1.40, 3: 0.90 };
|
|
||||||
|
|
||||||
function baseByCfm(cfm) {
|
let _deriveRateTimer = null;
|
||||||
if (cfm <= 0) return 0;
|
|
||||||
if (cfm <= 5) return 15;
|
|
||||||
if (cfm <= 10) return 30;
|
|
||||||
if (cfm <= 15) return 50;
|
|
||||||
if (cfm <= 25) return 80;
|
|
||||||
if (cfm <= 40) return 130;
|
|
||||||
if (cfm <= 60) return 200;
|
|
||||||
return 300;
|
|
||||||
}
|
|
||||||
|
|
||||||
function deriveBlastRate(cfm, nozzle, setupType, substrate, override) {
|
function updateBlastSetupDerivedRate() {
|
||||||
if (override && parseFloat(override) > 0) return parseFloat(override);
|
clearTimeout(_deriveRateTimer);
|
||||||
const base = baseByCfm(parseFloat(cfm) || 0);
|
_deriveRateTimer = setTimeout(function () {
|
||||||
if (base === 0) return 0;
|
const cfm = document.getElementById('blastSetupCfm').value;
|
||||||
const nm = blastNozzleMultipliers[parseInt(nozzle)] || 1.00;
|
const nozzle = document.getElementById('blastSetupNozzleSize').value;
|
||||||
const sm = blastSetupModalTypeMultipliers[parseInt(setupType)] || 1.00;
|
const setupType = document.getElementById('blastSetupModalType').value;
|
||||||
const bm = blastSubstrateMultipliers[parseInt(substrate)] || 1.00;
|
const substrate = document.getElementById('blastSetupSubstrate').value;
|
||||||
return Math.round(base * nm * sm * bm * 10) / 10;
|
const override = document.getElementById('blastSetupOverride').value;
|
||||||
|
const el = document.getElementById('blastSetupDerivedRate');
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const params = new URLSearchParams({ cfm, nozzle, setupType, substrate });
|
||||||
|
if (override && parseFloat(override) > 0) params.set('rateOverride', override);
|
||||||
|
|
||||||
|
fetch('/CompanySettings/DeriveBlastRate?' + params)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => { el.textContent = data.rate > 0 ? data.rate + ' sqft/hr' : '—'; })
|
||||||
|
.catch(() => { el.textContent = '—'; });
|
||||||
|
}, 250);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.loadBlastSetups = function () {
|
window.loadBlastSetups = function () {
|
||||||
@@ -1150,7 +1151,7 @@
|
|||||||
window.blastSetups.forEach(function (setup) {
|
window.blastSetups.forEach(function (setup) {
|
||||||
const rate = setup.blastRateSqFtPerHourOverride > 0
|
const rate = setup.blastRateSqFtPerHourOverride > 0
|
||||||
? setup.blastRateSqFtPerHourOverride + ' sqft/hr <span class="badge bg-secondary">Override</span>'
|
? setup.blastRateSqFtPerHourOverride + ' sqft/hr <span class="badge bg-secondary">Override</span>'
|
||||||
: deriveBlastRate(setup.compressorCfm, setup.blastNozzleSize, setup.setupType, setup.primarySubstrate, 0) + ' sqft/hr';
|
: (setup.derivedRate > 0 ? setup.derivedRate + ' sqft/hr' : '<span class="text-muted">—</span>');
|
||||||
|
|
||||||
const defaultBadge = setup.isDefault
|
const defaultBadge = setup.isDefault
|
||||||
? ' <span class="badge bg-primary ms-1">Default</span>'
|
? ' <span class="badge bg-primary ms-1">Default</span>'
|
||||||
@@ -1179,20 +1180,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateBlastSetupDerivedRate() {
|
|
||||||
const cfm = document.getElementById('blastSetupCfm').value;
|
|
||||||
const nozzle = document.getElementById('blastSetupNozzleSize').value;
|
|
||||||
const setupType = document.getElementById('blastSetupModalType').value;
|
|
||||||
const substrate = document.getElementById('blastSetupSubstrate').value;
|
|
||||||
const override = document.getElementById('blastSetupOverride').value;
|
|
||||||
|
|
||||||
const rate = deriveBlastRate(cfm, nozzle, setupType, substrate, override);
|
|
||||||
const el = document.getElementById('blastSetupDerivedRate');
|
|
||||||
if (el) {
|
|
||||||
el.textContent = rate > 0 ? rate + ' sqft/hr' : '—';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.showBlastSetupModal = function (setupId = null) {
|
window.showBlastSetupModal = function (setupId = null) {
|
||||||
const modal = new bootstrap.Modal(document.getElementById('blastSetupModal'));
|
const modal = new bootstrap.Modal(document.getElementById('blastSetupModal'));
|
||||||
const form = document.getElementById('blastSetupForm');
|
const form = document.getElementById('blastSetupForm');
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ async function openUsageEdit(transactionId) {
|
|||||||
|
|
||||||
document.getElementById('euTxnId').value = data.transactionId;
|
document.getElementById('euTxnId').value = data.transactionId;
|
||||||
document.getElementById('euItemName').textContent = data.itemName || '—';
|
document.getElementById('euItemName').textContent = data.itemName || '—';
|
||||||
|
document.getElementById('euQuantity').value = data.quantity != null ? parseFloat(data.quantity).toFixed(4) : '';
|
||||||
document.getElementById('euDate').value = data.transactionDate;
|
document.getElementById('euDate').value = data.transactionDate;
|
||||||
document.getElementById('euNotes').value = data.notes || '';
|
document.getElementById('euNotes').value = data.notes || '';
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@ document.getElementById('euSaveBtn').addEventListener('click', async () => {
|
|||||||
const token = form.querySelector('input[name="__RequestVerificationToken"]')?.value;
|
const token = form.querySelector('input[name="__RequestVerificationToken"]')?.value;
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
id: document.getElementById('euTxnId').value,
|
id: document.getElementById('euTxnId').value,
|
||||||
|
quantity: document.getElementById('euQuantity').value,
|
||||||
jobId: document.getElementById('euJobId').value,
|
jobId: document.getElementById('euJobId').value,
|
||||||
notes: document.getElementById('euNotes').value,
|
notes: document.getElementById('euNotes').value,
|
||||||
transactionDate: document.getElementById('euDate').value,
|
transactionDate: document.getElementById('euDate').value,
|
||||||
|
|||||||
@@ -691,7 +691,7 @@ function renderSalesFields() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="wzMerchDropdown"
|
<div id="wzMerchDropdown"
|
||||||
style="display:none;max-height:220px;overflow-y:auto;z-index:1060;background:#fff;border:1px solid #dee2e6;border-radius:0.375rem;box-shadow:0 4px 12px rgba(0,0,0,.12);position:fixed;">
|
style="display:none;max-height:220px;overflow-y:auto;z-index:1060;background:var(--bs-body-bg);color:var(--bs-body-color);border:1px solid var(--bs-border-color);border-radius:0.375rem;box-shadow:0 4px 12px rgba(0,0,0,.12);position:fixed;">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-danger d-none mt-1" id="err_salesCatalogItemId">Please select an item.</div>
|
<div class="text-danger d-none mt-1" id="err_salesCatalogItemId">Please select an item.</div>
|
||||||
@@ -773,7 +773,7 @@ function wzMerchComboRender(query) {
|
|||||||
`<div class="wz-merch-opt" style="padding:.35rem .75rem .35rem 1.25rem;font-size:.875rem;cursor:pointer;"
|
`<div class="wz-merch-opt" style="padding:.35rem .75rem .35rem 1.25rem;font-size:.875rem;cursor:pointer;"
|
||||||
data-id="${m.id}" data-name="${escHtml(m.name)}" data-price="${m.price}" data-sku="${escHtml(m.sku || '')}"
|
data-id="${m.id}" data-name="${escHtml(m.name)}" data-price="${m.price}" data-sku="${escHtml(m.sku || '')}"
|
||||||
onmousedown="event.preventDefault();wzMerchComboSelect(this)"
|
onmousedown="event.preventDefault();wzMerchComboSelect(this)"
|
||||||
onmouseenter="this.style.background='#f0f4ff'"
|
onmouseenter="this.style.background='var(--bs-secondary-bg)'"
|
||||||
onmouseleave="this.style.background=''">
|
onmouseleave="this.style.background=''">
|
||||||
${escHtml(m.name)}${m.sku ? ` <span class="text-muted">[${escHtml(m.sku)}]</span>` : ''} <span class="text-muted">— $${parseFloat(m.price).toFixed(2)}</span>
|
${escHtml(m.name)}${m.sku ? ` <span class="text-muted">[${escHtml(m.sku)}]</span>` : ''} <span class="text-muted">— $${parseFloat(m.price).toFixed(2)}</span>
|
||||||
</div>`
|
</div>`
|
||||||
@@ -1510,8 +1510,11 @@ async function aiAnalyze() {
|
|||||||
document.getElementById('ai_resultsSection')?.classList.add('d-none');
|
document.getElementById('ai_resultsSection')?.classList.add('d-none');
|
||||||
document.getElementById('ai_errorAlert')?.classList.add('d-none');
|
document.getElementById('ai_errorAlert')?.classList.add('d-none');
|
||||||
|
|
||||||
const blastSetupIdEl = document.getElementById('ai_blastSetupId');
|
const blastSetupIdEl = document.getElementById('ai_blastSetupId');
|
||||||
const blastSetupId = blastSetupIdEl ? (parseInt(blastSetupIdEl.value) || null) : null;
|
const _defaultSetup = blastSetupData.find(s => s.isDefault) || blastSetupData[0];
|
||||||
|
const blastSetupId = blastSetupIdEl
|
||||||
|
? (parseInt(blastSetupIdEl.value) || null)
|
||||||
|
: (_defaultSetup ? _defaultSetup.id : null);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
photoTempIds: wz.ai.tempIds,
|
photoTempIds: wz.ai.tempIds,
|
||||||
@@ -1590,8 +1593,11 @@ async function aiSendFollowup() {
|
|||||||
const weightLbs = isNaN(weightLbsRaw) || weightLbsRaw <= 0 ? null : weightLbsRaw;
|
const weightLbs = isNaN(weightLbsRaw) || weightLbsRaw <= 0 ? null : weightLbsRaw;
|
||||||
wz.data.quantity = qty; // persist before renderStep re-renders
|
wz.data.quantity = qty; // persist before renderStep re-renders
|
||||||
|
|
||||||
const blastSetupIdEl2 = document.getElementById('ai_blastSetupId');
|
const blastSetupIdEl2 = document.getElementById('ai_blastSetupId');
|
||||||
const blastSetupId2 = blastSetupIdEl2 ? (parseInt(blastSetupIdEl2.value) || null) : null;
|
const _defaultSetup2 = blastSetupData.find(s => s.isDefault) || blastSetupData[0];
|
||||||
|
const blastSetupId2 = blastSetupIdEl2
|
||||||
|
? (parseInt(blastSetupIdEl2.value) || null)
|
||||||
|
: (_defaultSetup2 ? _defaultSetup2.id : null);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
photoTempIds: wz.ai.tempIds,
|
photoTempIds: wz.ai.tempIds,
|
||||||
@@ -1909,7 +1915,7 @@ function buildCoatRowHtml(i, coat) {
|
|||||||
<input type="hidden" id="coat_inventoryItemId_${i}">
|
<input type="hidden" id="coat_inventoryItemId_${i}">
|
||||||
<div id="coat_powder_dropdown_${i}"
|
<div id="coat_powder_dropdown_${i}"
|
||||||
class="powder-combo-dropdown"
|
class="powder-combo-dropdown"
|
||||||
style="display:none;max-height:220px;overflow-y:auto;z-index:1060;border-radius:0.375rem;box-shadow:0 4px 12px rgba(0,0,0,.12);">
|
style="display:none;max-height:220px;overflow-y:auto;z-index:1060;background:var(--bs-body-bg);color:var(--bs-body-color);border:1px solid var(--bs-border-color);border-radius:0.375rem;box-shadow:0 4px 12px rgba(0,0,0,.12);">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1968,7 +1974,7 @@ function buildCoatRowHtml(i, coat) {
|
|||||||
</div>
|
</div>
|
||||||
<div id="coat_catalog_results_${i}"
|
<div id="coat_catalog_results_${i}"
|
||||||
class="powder-combo-dropdown"
|
class="powder-combo-dropdown"
|
||||||
style="display:none;max-height:220px;overflow-y:auto;z-index:1060;border-radius:0.375rem;box-shadow:0 4px 12px rgba(0,0,0,.12);">
|
style="display:none;max-height:220px;overflow-y:auto;z-index:1060;background:var(--bs-body-bg);color:var(--bs-body-color);border:1px solid var(--bs-border-color);border-radius:0.375rem;box-shadow:0 4px 12px rgba(0,0,0,.12);">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-6">
|
<div class="col-sm-6">
|
||||||
@@ -2166,7 +2172,7 @@ function powderComboRender(i, query) {
|
|||||||
data-val="${escHtml(String(p.value))}"
|
data-val="${escHtml(String(p.value))}"
|
||||||
data-txt="${escHtml(p.text)}"
|
data-txt="${escHtml(p.text)}"
|
||||||
onmousedown="event.preventDefault(); powderComboSelect(${i}, this.dataset.val, this.dataset.txt)"
|
onmousedown="event.preventDefault(); powderComboSelect(${i}, this.dataset.val, this.dataset.txt)"
|
||||||
onmouseenter="this.style.background=document.documentElement.getAttribute('data-bs-theme')==='dark'?'#2c3a5a':'#f0f4ff'"
|
onmouseenter="this.style.background='var(--bs-secondary-bg)'"
|
||||||
onmouseleave="this.classList.contains('pw-active')?null:this.style.background=''">
|
onmouseleave="this.classList.contains('pw-active')?null:this.style.background=''">
|
||||||
${escHtml(displayText)}${badge}
|
${escHtml(displayText)}${badge}
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -2214,12 +2220,12 @@ function powderComboKey(event, i) {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
idx = Math.min(idx + 1, items.length - 1);
|
idx = Math.min(idx + 1, items.length - 1);
|
||||||
items.forEach(it => { it.classList.remove('pw-active'); it.style.background = ''; });
|
items.forEach(it => { it.classList.remove('pw-active'); it.style.background = ''; });
|
||||||
if (items[idx]) { items[idx].classList.add('pw-active'); items[idx].style.background = '#e8eeff'; items[idx].scrollIntoView({ block: 'nearest' }); }
|
if (items[idx]) { items[idx].classList.add('pw-active'); items[idx].style.background = 'var(--bs-primary-bg-subtle)'; items[idx].scrollIntoView({ block: 'nearest' }); }
|
||||||
} else if (event.key === 'ArrowUp') {
|
} else if (event.key === 'ArrowUp') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
idx = Math.max(idx - 1, 0);
|
idx = Math.max(idx - 1, 0);
|
||||||
items.forEach(it => { it.classList.remove('pw-active'); it.style.background = ''; });
|
items.forEach(it => { it.classList.remove('pw-active'); it.style.background = ''; });
|
||||||
if (items[idx]) { items[idx].classList.add('pw-active'); items[idx].style.background = '#e8eeff'; items[idx].scrollIntoView({ block: 'nearest' }); }
|
if (items[idx]) { items[idx].classList.add('pw-active'); items[idx].style.background = 'var(--bs-primary-bg-subtle)'; items[idx].scrollIntoView({ block: 'nearest' }); }
|
||||||
} else if (event.key === 'Enter') {
|
} else if (event.key === 'Enter') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const active = dd.querySelector('.pw-active') || items[0];
|
const active = dd.querySelector('.pw-active') || items[0];
|
||||||
@@ -2272,7 +2278,7 @@ function customPowderCatalogSearch(i, query) {
|
|||||||
const price = r.unitPrice ? `<span class="text-muted small ms-1">$${parseFloat(r.unitPrice).toFixed(2)}/lb</span>` : '';
|
const price = r.unitPrice ? `<span class="text-muted small ms-1">$${parseFloat(r.unitPrice).toFixed(2)}/lb</span>` : '';
|
||||||
return `<div class="powder-opt" style="padding:.4rem .75rem;font-size:.83rem;white-space:normal;line-height:1.3;cursor:pointer;"
|
return `<div class="powder-opt" style="padding:.4rem .75rem;font-size:.83rem;white-space:normal;line-height:1.3;cursor:pointer;"
|
||||||
onmousedown="event.preventDefault(); applyCustomCatalogResult(${i}, ${JSON.stringify(r).replace(/"/g, '"')})"
|
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=''">
|
onmouseleave="this.style.background=''">
|
||||||
<strong>${escHtml(r.colorName)}</strong> — ${escHtml(r.vendorName)}
|
<strong>${escHtml(r.colorName)}</strong> — ${escHtml(r.vendorName)}
|
||||||
<span class="text-muted small ms-1">${escHtml(r.sku || '')}</span>
|
<span class="text-muted small ms-1">${escHtml(r.sku || '')}</span>
|
||||||
@@ -2363,7 +2369,7 @@ function powderCatalogSearch(i, query) {
|
|||||||
: '';
|
: '';
|
||||||
return `<div class="powder-opt" style="padding:.35rem .75rem;font-size:.83rem;white-space:normal;line-height:1.3;cursor:pointer;"
|
return `<div class="powder-opt" style="padding:.35rem .75rem;font-size:.83rem;white-space:normal;line-height:1.3;cursor:pointer;"
|
||||||
onmousedown="event.preventDefault(); createIncomingFromCatalog(${i}, ${r.id})"
|
onmousedown="event.preventDefault(); createIncomingFromCatalog(${i}, ${r.id})"
|
||||||
onmouseenter="this.style.background='#fff8e1'"
|
onmouseenter="this.style.background='var(--bs-warning-bg-subtle)'"
|
||||||
onmouseleave="this.style.background=''">
|
onmouseleave="this.style.background=''">
|
||||||
<i class="bi bi-truck text-warning me-1"></i>
|
<i class="bi bi-truck text-warning me-1"></i>
|
||||||
<strong>${escHtml(r.colorName)}</strong> — ${escHtml(r.vendorName)} ${escHtml(r.sku || '')}
|
<strong>${escHtml(r.colorName)}</strong> — ${escHtml(r.vendorName)} ${escHtml(r.sku || '')}
|
||||||
|
|||||||
@@ -6,9 +6,64 @@
|
|||||||
let _items = [];
|
let _items = [];
|
||||||
let _jobPowderIds = new Set();
|
let _jobPowderIds = new Set();
|
||||||
let _modal = null;
|
let _modal = null;
|
||||||
|
let _selectedItemId = 0;
|
||||||
|
let _entryMethod = 'used'; // 'used' | 'remaining'
|
||||||
|
|
||||||
|
// ── Mode toggle ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
window.lmSetMethod = function (method) {
|
||||||
|
_entryMethod = method;
|
||||||
|
const btnUsed = document.getElementById('lmBtnUsed');
|
||||||
|
const btnRemaining = document.getElementById('lmBtnRemaining');
|
||||||
|
const hintEl = document.getElementById('lmMethodHint');
|
||||||
|
const qtyLabel = document.getElementById('lmQtyLabel');
|
||||||
|
|
||||||
|
if (method === 'remaining') {
|
||||||
|
btnUsed.className = 'btn btn-outline-primary';
|
||||||
|
btnRemaining.className = 'btn btn-primary';
|
||||||
|
hintEl.textContent = 'Enter how much is LEFT in the bag — the system calculates what was used.';
|
||||||
|
qtyLabel.innerHTML = 'Weight Remaining in Bag <span class="text-danger">*</span>';
|
||||||
|
} else {
|
||||||
|
btnUsed.className = 'btn btn-primary';
|
||||||
|
btnRemaining.className = 'btn btn-outline-primary';
|
||||||
|
hintEl.textContent = 'Enter how much powder you took out of the bag.';
|
||||||
|
qtyLabel.innerHTML = 'Quantity Used <span class="text-danger">*</span>';
|
||||||
|
}
|
||||||
|
lmUpdatePreview();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Live preview (always visible once qty + item are set) ─────────────────
|
||||||
|
|
||||||
|
function lmUpdatePreview() {
|
||||||
|
const computedDiv = document.getElementById('lmComputedUsed');
|
||||||
|
if (!_selectedItemId || !computedDiv) { computedDiv?.classList.add('d-none'); return; }
|
||||||
|
const item = _items.find(it => it.id === _selectedItemId);
|
||||||
|
const onHand = item ? (parseFloat(item.quantityOnHand) || 0) : 0;
|
||||||
|
const qty = parseFloat(document.getElementById('lmQuantity').value) || 0;
|
||||||
|
if (qty <= 0) { computedDiv.classList.add('d-none'); return; }
|
||||||
|
|
||||||
|
const uom = item?.unitOfMeasure || '';
|
||||||
|
if (_entryMethod === 'remaining') {
|
||||||
|
const used = onHand - qty;
|
||||||
|
if (used <= 0) {
|
||||||
|
computedDiv.className = 'form-text fw-semibold text-danger';
|
||||||
|
computedDiv.textContent = 'Remaining cannot be ≥ current stock (' + onHand.toFixed(2) + ' ' + uom + ').';
|
||||||
|
} else {
|
||||||
|
computedDiv.className = 'form-text fw-semibold text-success';
|
||||||
|
computedDiv.textContent =
|
||||||
|
'Will log ' + used.toFixed(2) + ' ' + uom + ' used — new balance: ' + qty.toFixed(2) + ' ' + uom;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const newBal = onHand - qty;
|
||||||
|
const col = newBal < 0 ? 'text-danger' : 'text-success';
|
||||||
|
computedDiv.className = 'form-text fw-semibold ' + col;
|
||||||
|
computedDiv.textContent =
|
||||||
|
'Will log ' + qty.toFixed(2) + ' ' + uom + ' used — new balance: ' + newBal.toFixed(2) + ' ' + uom;
|
||||||
|
}
|
||||||
|
computedDiv.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
// ── Combobox state ────────────────────────────────────────────────────────
|
// ── Combobox state ────────────────────────────────────────────────────────
|
||||||
let _selectedItemId = 0;
|
|
||||||
|
|
||||||
function lmComboInput() {
|
function lmComboInput() {
|
||||||
const q = document.getElementById('lmItemSearch')?.value?.toLowerCase() || '';
|
const q = document.getElementById('lmItemSearch')?.value?.toLowerCase() || '';
|
||||||
@@ -16,7 +71,7 @@
|
|||||||
lmComboShow();
|
lmComboShow();
|
||||||
_selectedItemId = 0;
|
_selectedItemId = 0;
|
||||||
document.getElementById('lmItemBalance').classList.add('d-none');
|
document.getElementById('lmItemBalance').classList.add('d-none');
|
||||||
lmOnQtyInput();
|
lmUpdatePreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
function lmComboOpen() {
|
function lmComboOpen() {
|
||||||
@@ -111,7 +166,7 @@
|
|||||||
const balDiv = document.getElementById('lmItemBalance');
|
const balDiv = document.getElementById('lmItemBalance');
|
||||||
balDiv.textContent = 'Current stock: ' + qty.toFixed(2) + (uom ? ' ' + uom : '');
|
balDiv.textContent = 'Current stock: ' + qty.toFixed(2) + (uom ? ' ' + uom : '');
|
||||||
balDiv.classList.remove('d-none');
|
balDiv.classList.remove('d-none');
|
||||||
lmOnQtyInput();
|
lmUpdatePreview();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.lmComboInput = lmComboInput;
|
window.lmComboInput = lmComboInput;
|
||||||
@@ -152,39 +207,14 @@
|
|||||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Quantity / label logic ────────────────────────────────────────────────
|
// ── Kept for backward-compat with any inline onchange handlers that may exist ─
|
||||||
|
window.lmUpdateQuantityLabel = function () { lmUpdatePreview(); };
|
||||||
function lmOnQtyInput() {
|
|
||||||
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
|
|
||||||
if (method !== 'remaining') {
|
|
||||||
document.getElementById('lmComputedUsed').classList.add('d-none');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!_selectedItemId) {
|
|
||||||
document.getElementById('lmComputedUsed').classList.add('d-none');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const item = _items.find(it => it.id === _selectedItemId);
|
|
||||||
const onHand = item ? (parseFloat(item.quantityOnHand) || 0) : 0;
|
|
||||||
const remaining = parseFloat(document.getElementById('lmQuantity').value) || 0;
|
|
||||||
const used = onHand - remaining;
|
|
||||||
const computedDiv = document.getElementById('lmComputedUsed');
|
|
||||||
computedDiv.textContent = 'Usage = ' + onHand.toFixed(2) + ' − ' + remaining.toFixed(2) + ' = ' + used.toFixed(2) + (item?.unitOfMeasure ? ' ' + item.unitOfMeasure : '');
|
|
||||||
computedDiv.classList.remove('d-none');
|
|
||||||
}
|
|
||||||
|
|
||||||
window.lmUpdateQuantityLabel = function () {
|
|
||||||
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
|
|
||||||
document.getElementById('lmQtyLabel').innerHTML =
|
|
||||||
(method === 'remaining' ? 'Quantity Remaining' : 'Quantity Used') +
|
|
||||||
' <span class="text-danger">*</span>';
|
|
||||||
lmOnQtyInput();
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Modal open / save ─────────────────────────────────────────────────────
|
// ── Modal open / save ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
window.openLogMaterialModal = function () {
|
window.openLogMaterialModal = function () {
|
||||||
_selectedItemId = 0;
|
_selectedItemId = 0;
|
||||||
|
_entryMethod = 'used';
|
||||||
document.getElementById('lmItemSearch').value = '';
|
document.getElementById('lmItemSearch').value = '';
|
||||||
document.getElementById('lmItemBalance').classList.add('d-none');
|
document.getElementById('lmItemBalance').classList.add('d-none');
|
||||||
document.getElementById('lmQuantity').value = '';
|
document.getElementById('lmQuantity').value = '';
|
||||||
@@ -193,8 +223,7 @@
|
|||||||
document.getElementById('lmNotes').value = '';
|
document.getElementById('lmNotes').value = '';
|
||||||
document.getElementById('lmAlert').classList.add('d-none');
|
document.getElementById('lmAlert').classList.add('d-none');
|
||||||
document.getElementById('lmSaveBtn').disabled = false;
|
document.getElementById('lmSaveBtn').disabled = false;
|
||||||
document.getElementById('lmMethodUsed').checked = true;
|
lmSetMethod('used');
|
||||||
window.lmUpdateQuantityLabel();
|
|
||||||
lmComboClose();
|
lmComboClose();
|
||||||
if (_modal) _modal.show();
|
if (_modal) _modal.show();
|
||||||
};
|
};
|
||||||
@@ -214,14 +243,14 @@
|
|||||||
const qtyInput = parseFloat(document.getElementById('lmQuantity').value) || 0;
|
const qtyInput = parseFloat(document.getElementById('lmQuantity').value) || 0;
|
||||||
if (qtyInput <= 0) { showError('Please enter a quantity greater than zero.'); return; }
|
if (qtyInput <= 0) { showError('Please enter a quantity greater than zero.'); return; }
|
||||||
|
|
||||||
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
|
const item = _items.find(it => it.id === _selectedItemId);
|
||||||
|
const onHand = item ? (parseFloat(item.quantityOnHand) || 0) : 0;
|
||||||
|
|
||||||
let quantityUsed = qtyInput;
|
let quantityUsed = qtyInput;
|
||||||
if (method === 'remaining') {
|
if (_entryMethod === 'remaining') {
|
||||||
const item = _items.find(it => it.id === _selectedItemId);
|
|
||||||
const onHand = item ? (parseFloat(item.quantityOnHand) || 0) : 0;
|
|
||||||
quantityUsed = onHand - qtyInput;
|
quantityUsed = onHand - qtyInput;
|
||||||
if (quantityUsed <= 0) {
|
if (quantityUsed <= 0) {
|
||||||
showError('Remaining quantity cannot be equal to or greater than the current stock (' + onHand.toFixed(2) + ').');
|
showError('Remaining cannot be equal to or greater than the current stock (' + onHand.toFixed(2) + ').');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -269,9 +298,8 @@
|
|||||||
_jobPowderIds = new Set(cfg.jobPowderIds || []);
|
_jobPowderIds = new Set(cfg.jobPowderIds || []);
|
||||||
_modal = new bootstrap.Modal(document.getElementById('logMaterialModal'));
|
_modal = new bootstrap.Modal(document.getElementById('logMaterialModal'));
|
||||||
|
|
||||||
document.getElementById('lmQuantity').addEventListener('input', lmOnQtyInput);
|
document.getElementById('lmQuantity').addEventListener('input', lmUpdatePreview);
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
|
||||||
document.addEventListener('click', function (e) {
|
document.addEventListener('click', function (e) {
|
||||||
if (!e.target.closest('#lmItemSearch') &&
|
if (!e.target.closest('#lmItemSearch') &&
|
||||||
!e.target.closest('#lmItemDropdown') &&
|
!e.target.closest('#lmItemDropdown') &&
|
||||||
|
|||||||
@@ -24,24 +24,12 @@ public class ShopCapabilityCalculatorTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GetBlastRateSqFtPerHour_WithNoCompressorCfm_ReturnsZero()
|
public void GetBlastRateSqFtPerHour_PressurePot_Nozzle6_Paint()
|
||||||
{
|
{
|
||||||
|
// PressurePotRateByNozzle(6) = 245 * SubstrateMultiplier(Paint) 1.0 = 245
|
||||||
var costs = new CompanyOperatingCosts
|
var costs = new CompanyOperatingCosts
|
||||||
{
|
{
|
||||||
CompressorCfm = 0m
|
CompressorCfm = 200m,
|
||||||
};
|
|
||||||
|
|
||||||
var result = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(costs);
|
|
||||||
|
|
||||||
Assert.Equal(0m, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetBlastRateSqFtPerHour_DerivesRateFromEquipmentInputs()
|
|
||||||
{
|
|
||||||
var costs = new CompanyOperatingCosts
|
|
||||||
{
|
|
||||||
CompressorCfm = 150m,
|
|
||||||
BlastNozzleSize = 6,
|
BlastNozzleSize = 6,
|
||||||
BlastSetupType = BlastSetupType.PressurePot,
|
BlastSetupType = BlastSetupType.PressurePot,
|
||||||
PrimaryBlastSubstrate = BlastSubstrateType.Paint
|
PrimaryBlastSubstrate = BlastSubstrateType.Paint
|
||||||
@@ -49,16 +37,17 @@ public class ShopCapabilityCalculatorTests
|
|||||||
|
|
||||||
var result = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(costs);
|
var result = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(costs);
|
||||||
|
|
||||||
Assert.Equal(58.5m, result);
|
Assert.Equal(245m, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GetBlastRateSqFtPerHour_ForNamedSetup_UsesSetupOverload()
|
public void GetBlastRateSqFtPerHour_SiphonCabinet_Nozzle4_Mixed()
|
||||||
{
|
{
|
||||||
|
// SiphonCabinetRateByNozzle(4) = 125 * SubstrateMultiplier(Mixed) 0.9 = 112.5
|
||||||
var setup = new CompanyBlastSetup
|
var setup = new CompanyBlastSetup
|
||||||
{
|
{
|
||||||
Name = "Main Cabinet",
|
Name = "Main Cabinet",
|
||||||
CompressorCfm = 7m,
|
CompressorCfm = 42m,
|
||||||
BlastNozzleSize = 4,
|
BlastNozzleSize = 4,
|
||||||
SetupType = BlastSetupType.SiphonCabinet,
|
SetupType = BlastSetupType.SiphonCabinet,
|
||||||
PrimarySubstrate = BlastSubstrateType.Mixed
|
PrimarySubstrate = BlastSubstrateType.Mixed
|
||||||
@@ -66,7 +55,39 @@ public class ShopCapabilityCalculatorTests
|
|||||||
|
|
||||||
var result = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(setup);
|
var result = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(setup);
|
||||||
|
|
||||||
Assert.Equal(1.7m, result);
|
Assert.Equal(112.5m, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetBlastRateSqFtPerHour_PressurePot_Nozzle4_RustAndScale()
|
||||||
|
{
|
||||||
|
// PressurePotRateByNozzle(4) = 115 * SubstrateMultiplier(RustAndScale) 0.7 = 80.5
|
||||||
|
var costs = new CompanyOperatingCosts
|
||||||
|
{
|
||||||
|
BlastNozzleSize = 4,
|
||||||
|
BlastSetupType = BlastSetupType.PressurePot,
|
||||||
|
PrimaryBlastSubstrate = BlastSubstrateType.RustAndScale
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(costs);
|
||||||
|
|
||||||
|
Assert.Equal(80.5m, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetBlastRateSqFtPerHour_WetBlasting_Is60PctOfPressurePot()
|
||||||
|
{
|
||||||
|
// WetBlasting = PressurePotRateByNozzle(5) * 0.6 * substrate(Paint 1.0) = 175 * 0.6 = 105
|
||||||
|
var costs = new CompanyOperatingCosts
|
||||||
|
{
|
||||||
|
BlastNozzleSize = 5,
|
||||||
|
BlastSetupType = BlastSetupType.WetBlasting,
|
||||||
|
PrimaryBlastSubstrate = BlastSubstrateType.Paint
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(costs);
|
||||||
|
|
||||||
|
Assert.Equal(105m, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
@@ -86,10 +107,10 @@ public class ShopCapabilityCalculatorTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(ShopCapabilityTier.Garage, BlastSetupType.SiphonCabinet, 7, 4, BlastSubstrateType.Mixed)]
|
[InlineData(ShopCapabilityTier.Garage, BlastSetupType.SiphonCabinet, 7, 3, BlastSubstrateType.Mixed)]
|
||||||
[InlineData(ShopCapabilityTier.Small, BlastSetupType.PressurePot, 40, 5, BlastSubstrateType.Mixed)]
|
[InlineData(ShopCapabilityTier.Small, BlastSetupType.PressurePot, 49, 3, BlastSubstrateType.Mixed)]
|
||||||
[InlineData(ShopCapabilityTier.Medium, BlastSetupType.PressurePot, 80, 5, BlastSubstrateType.Mixed)]
|
[InlineData(ShopCapabilityTier.Medium, BlastSetupType.PressurePot, 90, 4, BlastSubstrateType.Mixed)]
|
||||||
[InlineData(ShopCapabilityTier.Large, BlastSetupType.PressurePot, 150, 6, BlastSubstrateType.Mixed)]
|
[InlineData(ShopCapabilityTier.Large, BlastSetupType.PressurePot, 150, 5, BlastSubstrateType.Mixed)]
|
||||||
public void TierDefaults_ReturnExpectedPresetValues(
|
public void TierDefaults_ReturnExpectedPresetValues(
|
||||||
ShopCapabilityTier tier,
|
ShopCapabilityTier tier,
|
||||||
BlastSetupType expectedSetup,
|
BlastSetupType expectedSetup,
|
||||||
|
|||||||
Reference in New Issue
Block a user