Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 81dc34bab4 | |||
| b9e9449c8b | |||
| fd38785942 | |||
| 33277de727 | |||
| 4ac62551f4 | |||
| 7fa385aeb8 | |||
| 8452ea3fcd | |||
| 9b34ff564e | |||
| 24f3df1bbc | |||
| 551116d7e5 | |||
| 8768e9813b | |||
| 4a7087cc0c | |||
| 59b152c89f | |||
| 441898b52f | |||
| 3e30397302 | |||
| 31c5746e5b | |||
| 3f9ac27afa | |||
| df504674e9 | |||
| 07796b05c8 | |||
| 2bf8871892 | |||
| 8a0a564885 | |||
| dd4785b048 | |||
| e185e3b7e3 |
@@ -137,6 +137,13 @@ public class CreateJobDto
|
||||
[Display(Name = "Oven")]
|
||||
public int? OvenCostId { get; set; }
|
||||
|
||||
[Display(Name = "Batches")]
|
||||
[Range(1, 999)]
|
||||
public int OvenBatches { get; set; } = 1;
|
||||
|
||||
[Display(Name = "Cycle Time (min)")]
|
||||
public int? OvenCycleMinutes { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "Description is required")]
|
||||
[StringLength(2000, ErrorMessage = "Description cannot exceed 2000 characters")]
|
||||
[Display(Name = "Description")]
|
||||
@@ -208,6 +215,16 @@ public class UpdateJobDto
|
||||
[Display(Name = "Assigned Worker")]
|
||||
public string? AssignedUserId { get; set; }
|
||||
|
||||
[Display(Name = "Oven")]
|
||||
public int? OvenCostId { get; set; }
|
||||
|
||||
[Display(Name = "Batches")]
|
||||
[Range(1, 999)]
|
||||
public int OvenBatches { get; set; } = 1;
|
||||
|
||||
[Display(Name = "Cycle Time (min)")]
|
||||
public int? OvenCycleMinutes { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "Description is required")]
|
||||
[StringLength(2000, ErrorMessage = "Description cannot exceed 2000 characters")]
|
||||
[Display(Name = "Description")]
|
||||
@@ -381,6 +398,7 @@ public class JobItemCoatDto
|
||||
public decimal? PowderCostPerLb { get; set; }
|
||||
public decimal? PowderToOrder { get; set; }
|
||||
public decimal? ActualPowderUsedLbs { get; set; } // Filled during job completion
|
||||
public bool NoExtraLayerCharge { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -801,6 +801,7 @@ public class QuoteItemCoatDto
|
||||
public decimal CoatMaterialCost { get; set; }
|
||||
public decimal CoatLaborCost { get; set; }
|
||||
public decimal CoatTotalCost { get; set; }
|
||||
public bool NoExtraLayerCharge { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -91,4 +91,11 @@ public interface INotificationService
|
||||
/// Alert company staff when a Stripe chargeback (dispute) is opened on an invoice payment.
|
||||
/// </summary>
|
||||
Task NotifyChargebackAlertAsync(Invoice invoice, string disputeId, decimal amount, string reason);
|
||||
|
||||
/// <summary>
|
||||
/// Sends an appointment reminder email to the linked customer (if opted in) and writes a
|
||||
/// notification log row. Called by <see cref="PowderCoating.Web.BackgroundServices.AppointmentReminderBackgroundService"/>
|
||||
/// when the reminder window opens. In-app bell notification is handled by the caller.
|
||||
/// </summary>
|
||||
Task NotifyAppointmentReminderAsync(Appointment appointment);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ public interface IStripeConnectService
|
||||
decimal invoiceTotal,
|
||||
decimal surchargeAmount,
|
||||
string currency,
|
||||
string customerEmail,
|
||||
string invoiceNumber,
|
||||
int invoiceId);
|
||||
|
||||
@@ -33,7 +32,6 @@ public interface IStripeConnectService
|
||||
decimal depositAmount,
|
||||
decimal surchargeAmount,
|
||||
string currency,
|
||||
string customerEmail,
|
||||
string quoteNumber,
|
||||
int quoteId);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,26 @@ using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Converts quote/job data into persisted <see cref="JobItem"/>, <see cref="JobItemCoat"/>,
|
||||
/// and <see cref="JobItemPrepService"/> entities.
|
||||
///
|
||||
/// Three source types are supported, each with a matching overload:
|
||||
/// 1. <see cref="CreateQuoteItemDto"/> — quote wizard (new job from form data + fresh pricing result)
|
||||
/// 2. <see cref="QuoteItem"/> — quote-to-job conversion (copies a saved quote line)
|
||||
/// 3. <see cref="JobItem"/> — job duplication / template instantiation (copies an existing job line)
|
||||
///
|
||||
/// The private <see cref="JobItemSeed"/> / <see cref="JobItemCoatSeed"/> / <see cref="JobItemPrepServiceSeed"/>
|
||||
/// intermediary classes exist solely to give all three overload paths a single <see cref="BuildJobItem"/>
|
||||
/// construction site — avoiding subtle copy-paste drift where one overload forgets to copy a new field.
|
||||
/// </summary>
|
||||
public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a <see cref="JobItem"/> from a quote wizard DTO and a pre-calculated pricing result.
|
||||
/// Used when creating a job directly from the job form or from an approved quote via the wizard.
|
||||
/// Pricing is passed in separately because it was already computed upstream (CalculateQuoteItemPriceAsync).
|
||||
/// </summary>
|
||||
public JobItem CreateJobItem(CreateQuoteItemDto source, int jobId, int companyId, QuoteItemPricingResult pricing, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
@@ -42,6 +60,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds <see cref="JobItemCoat"/> records from the coat DTOs in the quote wizard form.
|
||||
/// PowderToOrder is recalculated server-side here (not trusted from the form) using surface area,
|
||||
/// quantity, coverage, and transfer efficiency — the wizard's displayed value is for UI only.
|
||||
/// </summary>
|
||||
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
@@ -62,7 +85,8 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb,
|
||||
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
|
||||
Notes = c.Notes
|
||||
Notes = c.Notes,
|
||||
NoExtraLayerCharge = c.NoExtraLayerCharge
|
||||
},
|
||||
jobItemId,
|
||||
companyId,
|
||||
@@ -70,6 +94,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
.ToList() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds <see cref="JobItemPrepService"/> records (sandblasting, masking, etc.) from the
|
||||
/// quote wizard DTO. These are per-item prep steps with individual time estimates that feed
|
||||
/// labor cost calculations and shop floor instructions.
|
||||
/// </summary>
|
||||
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
@@ -85,6 +114,13 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="JobItem"/> by copying a saved <see cref="QuoteItem"/> during quote-to-job conversion.
|
||||
/// Prices are taken directly from the quote snapshot — no repricing occurs — so the job starts with
|
||||
/// exactly the amounts that were approved by the customer.
|
||||
/// The first coat's color/finish is promoted to the job item's top-level fields for quick display
|
||||
/// (details remain in the coat records).
|
||||
/// </summary>
|
||||
public JobItem CreateJobItem(QuoteItem source, int jobId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
@@ -128,6 +164,12 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds <see cref="JobItemCoat"/> records from a saved <see cref="QuoteItem"/> during quote-to-job conversion.
|
||||
/// Coat appearance (color name, code, finish) is resolved from the linked <see cref="InventoryItem"/> if available,
|
||||
/// because the inventory record is the canonical source of truth for a product's appearance —
|
||||
/// the values typed into the quote form may be incomplete or informal.
|
||||
/// </summary>
|
||||
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
@@ -151,7 +193,8 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb,
|
||||
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
|
||||
Notes = c.Notes
|
||||
Notes = c.Notes,
|
||||
NoExtraLayerCharge = c.NoExtraLayerCharge
|
||||
},
|
||||
jobItemId,
|
||||
companyId,
|
||||
@@ -160,6 +203,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
.ToList() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies prep service records from a <see cref="QuoteItem"/> to a new job item during quote-to-job conversion.
|
||||
/// </summary>
|
||||
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
@@ -175,6 +221,12 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="JobItem"/> by cloning an existing one — used for job templates
|
||||
/// and rework duplication where an existing job line is reused on a new job.
|
||||
/// Prices are copied as-is from the source; the job controller is responsible for repricing
|
||||
/// if operating costs have changed since the original job was created.
|
||||
/// </summary>
|
||||
public JobItem CreateJobItem(JobItem source, int jobId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
@@ -214,6 +266,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clones coat records from an existing <see cref="JobItem"/> onto a new job item.
|
||||
/// PowderToOrder is copied verbatim (not recalculated) because the original job's powder
|
||||
/// quantities may have been manually adjusted after initial calculation.
|
||||
/// </summary>
|
||||
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
@@ -234,7 +291,8 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb,
|
||||
PowderToOrder = c.PowderToOrder,
|
||||
Notes = c.Notes
|
||||
Notes = c.Notes,
|
||||
NoExtraLayerCharge = c.NoExtraLayerCharge
|
||||
},
|
||||
jobItemId,
|
||||
companyId,
|
||||
@@ -242,6 +300,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
.ToList() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clones prep service records from an existing <see cref="JobItem"/> onto a new job item.
|
||||
/// </summary>
|
||||
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
@@ -257,6 +318,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single construction point for all <see cref="JobItem"/> creation paths.
|
||||
/// Centralised here so that adding a new field only requires one code change, not three.
|
||||
/// </summary>
|
||||
private static JobItem BuildJobItem(JobItemSeed seed, int jobId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return new JobItem
|
||||
@@ -293,6 +358,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single construction point for all <see cref="JobItemCoat"/> creation paths.
|
||||
/// </summary>
|
||||
private static JobItemCoat BuildJobItemCoat(JobItemCoatSeed seed, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return new JobItemCoat
|
||||
@@ -310,11 +378,17 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
PowderCostPerLb = seed.PowderCostPerLb,
|
||||
PowderToOrder = seed.PowderToOrder,
|
||||
Notes = seed.Notes,
|
||||
NoExtraLayerCharge = seed.NoExtraLayerCharge,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single construction point for all <see cref="JobItemPrepService"/> creation paths.
|
||||
/// Returns an empty list (not null) when <paramref name="seeds"/> is null so callers
|
||||
/// can safely iterate without a null check.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<JobItemPrepService> BuildJobItemPrepServices(IEnumerable<JobItemPrepServiceSeed>? seeds, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return seeds?
|
||||
@@ -330,6 +404,18 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
.ToList() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the pounds of powder needed to coat a batch, preferring the pre-stored value
|
||||
/// (which the user may have manually adjusted in the wizard) over a fresh recalculation.
|
||||
///
|
||||
/// Formula: (surfaceAreaSqFt × quantity) ÷ (coverageSqFtPerLb × transferEfficiency)
|
||||
///
|
||||
/// Industry defaults are applied when catalog data is missing:
|
||||
/// - Coverage: 30 sqft/lb (typical for standard powder at 2–3 mil DFT)
|
||||
/// - Transfer efficiency: 65% (industry average for electrostatic spray)
|
||||
/// These are conservative defaults that slightly overestimate powder needed — intentional,
|
||||
/// so the shop doesn't run short on a job.
|
||||
/// </summary>
|
||||
private static decimal? CalculatePowderToOrder(decimal? storedPowderToOrder, decimal surfaceAreaSqFt, decimal quantity, decimal coverageSqFtPerLb, decimal transferEfficiency)
|
||||
{
|
||||
if (storedPowderToOrder.HasValue && storedPowderToOrder.Value > 0)
|
||||
@@ -343,6 +429,12 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
return Math.Round((surfaceAreaSqFt * quantity) / (coverage * efficiency), 2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the display appearance (color name, code, finish) for a coat, preferring the linked
|
||||
/// <see cref="InventoryItem"/>'s values over whatever was typed into the quote form.
|
||||
/// The inventory record is the canonical source of truth — the form values are used as a fallback
|
||||
/// only when no inventory item is linked (e.g. custom/one-off powder).
|
||||
/// </summary>
|
||||
private static (string? ColorName, string? ColorCode, string? Finish) ResolveCoatAppearance(
|
||||
string? colorName,
|
||||
string? colorCode,
|
||||
@@ -355,6 +447,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
return (inventoryItem.Name, inventoryItem.ColorCode, inventoryItem.Finish);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Intermediate value object that normalises the three different source types
|
||||
/// (DTO, QuoteItem, JobItem) into a single shape before the shared BuildJobItem factory method.
|
||||
/// Using a seed class prevents subtle bugs where an overload forgets to map a new field.
|
||||
/// </summary>
|
||||
private sealed class JobItemSeed
|
||||
{
|
||||
public string Description { get; init; } = string.Empty;
|
||||
@@ -385,6 +482,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
public int? AiPredictionId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Intermediate value object for coat creation — see <see cref="JobItemSeed"/> for rationale.</summary>
|
||||
private sealed class JobItemCoatSeed
|
||||
{
|
||||
public string CoatName { get; init; } = string.Empty;
|
||||
@@ -399,8 +497,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
public decimal? PowderCostPerLb { get; init; }
|
||||
public decimal? PowderToOrder { get; init; }
|
||||
public string? Notes { get; init; }
|
||||
public bool NoExtraLayerCharge { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Intermediate value object for prep service creation — see <see cref="JobItemSeed"/> for rationale.</summary>
|
||||
private sealed class JobItemPrepServiceSeed
|
||||
{
|
||||
public int PrepServiceId { get; init; }
|
||||
|
||||
@@ -6,6 +6,20 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates the full quote item assembly pipeline: pricing calculation, entity construction,
|
||||
/// AI prediction tracking, and automatic inventory record creation for incoming powder orders.
|
||||
///
|
||||
/// This service sits above <see cref="PricingCalculationService"/> — it knows HOW to build and
|
||||
/// persist quote entities, while PricingCalculationService knows HOW to compute dollar amounts.
|
||||
/// Keeping them separate means pricing logic can be unit-tested without any entity construction concerns.
|
||||
///
|
||||
/// Key responsibilities:
|
||||
/// - <see cref="ApplyPricingSnapshot"/> — stamps calculated totals onto the Quote entity so the
|
||||
/// displayed price is frozen at quote time and won't change if operating costs are updated later.
|
||||
/// - <see cref="CreateQuoteItemsAsync"/> — builds QuoteItem + coats + prep services for each DTO,
|
||||
/// records AI prediction overrides, and auto-creates incoming inventory records when needed.
|
||||
/// </summary>
|
||||
public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
@@ -25,6 +39,11 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the calculated pricing breakdown onto the <see cref="Quote"/> entity as a snapshot.
|
||||
/// Snapshots are critical: once a quote is sent to a customer, operating cost changes must NOT
|
||||
/// silently alter the quoted amounts — the snapshot preserves what was presented at the time.
|
||||
/// </summary>
|
||||
public void ApplyPricingSnapshot(Quote quote, QuotePricingResult pricingResult)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(quote);
|
||||
@@ -56,6 +75,12 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
quote.Total = pricingResult.Total;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds and prices all <see cref="QuoteItem"/> entities from the incoming DTOs.
|
||||
/// For each item: constructs the entity, calculates pricing, records whether the user overrode
|
||||
/// an AI estimate, then attaches coats (including auto-creating incoming inventory entries when
|
||||
/// the user selects a catalog powder not yet in their inventory) and prep services.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync(
|
||||
IEnumerable<CreateQuoteItemDto> itemDtos,
|
||||
int quoteId,
|
||||
@@ -80,6 +105,13 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
return items;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes a single item to the correct pricing path and stamps the result onto the entity.
|
||||
/// Priority order matches the routing table in <see cref="PricingCalculationService.CalculateQuoteItemPriceAsync"/>:
|
||||
/// AI items → Sales items → Catalog (no coats) → full calculation engine.
|
||||
/// Keeping pricing logic in PricingCalculationService means this method only decides WHICH
|
||||
/// path to take, never HOW to compute the price.
|
||||
/// </summary>
|
||||
private async Task ApplyPricingAsync(QuoteItem item, CreateQuoteItemDto itemDto, int companyId, decimal? ovenRateOverride)
|
||||
{
|
||||
if (itemDto.IsAiItem && itemDto.ManualUnitPrice.HasValue && itemDto.ManualUnitPrice.Value > 0)
|
||||
@@ -127,6 +159,12 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
ApplyCalculatedPricing(item, pricing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds <see cref="QuoteItemCoat"/> entities for a single item, including per-coat pricing.
|
||||
/// If a coat has <c>AddAsIncoming = true</c> and references a catalog item but not an inventory
|
||||
/// item, an incoming <see cref="InventoryItem"/> is auto-created so the shop can track the powder
|
||||
/// order and receive it later — see <see cref="CreateIncomingInventoryItemAsync"/> for details.
|
||||
/// </summary>
|
||||
private async Task<List<QuoteItemCoat>> BuildQuoteItemCoatsAsync(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
if (itemDto.Coats == null || itemDto.Coats.Count == 0)
|
||||
@@ -158,6 +196,7 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
return coats;
|
||||
}
|
||||
|
||||
/// <summary>Constructs <see cref="QuoteItemPrepService"/> entities from the item DTO's prep service list.</summary>
|
||||
private static List<QuoteItemPrepService> BuildQuoteItemPrepServices(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
if (itemDto.PrepServices == null || itemDto.PrepServices.Count == 0)
|
||||
@@ -175,6 +214,11 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constructs a bare <see cref="QuoteItem"/> entity from the DTO — no pricing or coats yet.
|
||||
/// Pricing is applied separately by <see cref="ApplyPricingAsync"/> to keep the construction
|
||||
/// and calculation steps distinct and individually testable.
|
||||
/// </summary>
|
||||
private static QuoteItem BuildQuoteItem(CreateQuoteItemDto itemDto, int quoteId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return new QuoteItem
|
||||
@@ -204,6 +248,7 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Constructs a <see cref="QuoteItemCoat"/> entity from the coat DTO. Per-coat pricing is applied by the caller.</summary>
|
||||
private static QuoteItemCoat BuildQuoteItemCoat(CreateQuoteItemCoatDto coatDto, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return new QuoteItemCoat
|
||||
@@ -219,12 +264,17 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
TransferEfficiency = coatDto.TransferEfficiency,
|
||||
PowderCostPerLb = coatDto.PowderCostPerLb,
|
||||
PowderToOrder = coatDto.PowderToOrder,
|
||||
NoExtraLayerCharge = coatDto.NoExtraLayerCharge,
|
||||
Notes = coatDto.Notes,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stamps the pricing result onto the quote item entity.
|
||||
/// Broken out as a separate method because it's called from multiple branches of ApplyPricingAsync.
|
||||
/// </summary>
|
||||
private static void ApplyCalculatedPricing(QuoteItem item, QuoteItemPricingResult pricing)
|
||||
{
|
||||
item.UnitPrice = pricing.UnitPrice;
|
||||
@@ -234,6 +284,13 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
item.ItemEquipmentCost = pricing.EquipmentCost;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the user changed the AI's surface area or price estimates before saving,
|
||||
/// and sets <c>UserOverrodeEstimate = true</c> on the prediction record if they did.
|
||||
/// This flag feeds the AI analytics reports — over time it reveals how accurate the AI is
|
||||
/// and whether certain item types consistently need manual correction.
|
||||
/// A tolerance of $0.01 / 0.01 sqft is used to ignore floating-point rounding noise.
|
||||
/// </summary>
|
||||
private async Task UpdateAiPredictionOverrideAsync(CreateQuoteItemDto itemDto, decimal finalUnitPrice)
|
||||
{
|
||||
if (!itemDto.AiPredictionId.HasValue) return;
|
||||
@@ -247,6 +304,23 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
prediction.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto-creates an "incoming" <see cref="InventoryItem"/> when a user selects a powder from the
|
||||
/// platform catalog that doesn't yet exist in their company's inventory.
|
||||
///
|
||||
/// WHY this exists: shops often quote jobs using powders they haven't ordered yet. Rather than
|
||||
/// forcing the user to manually add the powder to inventory before quoting, we create an
|
||||
/// IsIncoming=true record on their behalf. The shop can then receive the actual order against
|
||||
/// this record later (updating quantity + receive date) without losing the link to the original quote.
|
||||
///
|
||||
/// The AI augmentation step (LookupByUrlAsync) fills in technical specs (cure temp/time, coverage,
|
||||
/// color families, etc.) that may be missing from the scraped catalog JSON. It is best-effort —
|
||||
/// if it fails, the item is still created with whatever data the catalog has.
|
||||
///
|
||||
/// After creation, <c>coatDto.PowderCostPerLb</c> is cleared so the pricing engine treats this
|
||||
/// as an inventory-linked coat (not a custom powder), ensuring future repricings use the
|
||||
/// inventory unit cost rather than the now-stale manual price from the quote form.
|
||||
/// </summary>
|
||||
private async Task<int?> CreateIncomingInventoryItemAsync(CreateQuoteItemCoatDto coatDto, int companyId)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -95,6 +95,12 @@ public class Appointment : BaseEntity
|
||||
/// </summary>
|
||||
public int ReminderMinutesBefore { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the reminder was dispatched. Null means it hasn't fired yet.
|
||||
/// The background service uses this as a deduplication guard to prevent double-sending.
|
||||
/// </summary>
|
||||
public DateTime? ReminderSentAt { get; set; }
|
||||
|
||||
// Navigation Properties
|
||||
public virtual Customer? Customer { get; set; }
|
||||
public virtual Job? Job { get; set; }
|
||||
|
||||
@@ -42,6 +42,13 @@ public class JobItemCoat : BaseEntity
|
||||
public string? PowderReceivedByUserId { get; set; }
|
||||
public decimal? PowderReceivedLbs { get; set; }
|
||||
|
||||
// Pricing flags
|
||||
/// <summary>
|
||||
/// When true, the additional layer labor charge is not applied for this coat even if it is
|
||||
/// not the first coat in the sequence. Used for clear coats, sealers, etc.
|
||||
/// </summary>
|
||||
public bool NoExtraLayerCharge { get; set; }
|
||||
|
||||
// Notes
|
||||
public string? Notes { get; set; }
|
||||
|
||||
|
||||
@@ -33,6 +33,13 @@ public class QuoteItemCoat : BaseEntity
|
||||
public decimal CoatLaborCost { get; set; }
|
||||
public decimal CoatTotalCost { get; set; }
|
||||
|
||||
// Pricing flags
|
||||
/// <summary>
|
||||
/// When true, the additional layer labor charge is not applied for this coat even if it is
|
||||
/// not the first coat in the sequence. Used for clear coats, sealers, etc.
|
||||
/// </summary>
|
||||
public bool NoExtraLayerCharge { get; set; }
|
||||
|
||||
// Notes
|
||||
public string? Notes { get; set; }
|
||||
|
||||
|
||||
@@ -20,5 +20,7 @@ public enum NotificationType
|
||||
SmsInboundStop = 12,
|
||||
SmsInboundHelp = 13,
|
||||
AdminEmail = 14,
|
||||
SmsInboundStart = 15
|
||||
SmsInboundStart = 15,
|
||||
AppointmentReminder = 17,
|
||||
AppointmentReminderStaff = 18
|
||||
}
|
||||
|
||||
+10633
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -11,26 +11,20 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7851));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7856));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7858));
|
||||
// These UpdateData calls were generated from an existing live database.
|
||||
// On a fresh install the PricingTiers table and its seed rows may not exist yet
|
||||
// (seeding is manual via Platform Management → Seed Data), so guard each update.
|
||||
migrationBuilder.Sql(@"
|
||||
IF OBJECT_ID(N'[PricingTiers]', N'U') IS NOT NULL
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 1)
|
||||
UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377851Z' WHERE [Id] = 1;
|
||||
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 2)
|
||||
UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377856Z' WHERE [Id] = 2;
|
||||
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 3)
|
||||
UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377858Z' WHERE [Id] = 3;
|
||||
END
|
||||
");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
Generated
+10633
File diff suppressed because it is too large
Load Diff
+217
@@ -0,0 +1,217 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAppointmentReminderSentAt : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Use IF EXISTS guards for all ShopWorker drops — prod and dev diverged on whether
|
||||
// these objects exist, so unconditional drops would fail on whichever DB is missing them.
|
||||
migrationBuilder.Sql(@"
|
||||
IF EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = 'FK_Jobs_ShopWorkers_ShopWorkerId')
|
||||
ALTER TABLE [Jobs] DROP CONSTRAINT [FK_Jobs_ShopWorkers_ShopWorkerId];
|
||||
IF EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = 'FK_JobTimeEntries_ShopWorkers_ShopWorkerId')
|
||||
ALTER TABLE [JobTimeEntries] DROP CONSTRAINT [FK_JobTimeEntries_ShopWorkers_ShopWorkerId];
|
||||
IF EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = 'FK_MaintenanceRecords_ShopWorkers_ShopWorkerId')
|
||||
ALTER TABLE [MaintenanceRecords] DROP CONSTRAINT [FK_MaintenanceRecords_ShopWorkers_ShopWorkerId];
|
||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ShopWorkerRoleCosts')
|
||||
DROP TABLE [ShopWorkerRoleCosts];
|
||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ShopWorkers')
|
||||
DROP TABLE [ShopWorkers];
|
||||
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_MaintenanceRecords_ShopWorkerId' AND object_id = OBJECT_ID('MaintenanceRecords'))
|
||||
DROP INDEX [IX_MaintenanceRecords_ShopWorkerId] ON [MaintenanceRecords];
|
||||
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_JobTimeEntries_ShopWorkerId' AND object_id = OBJECT_ID('JobTimeEntries'))
|
||||
DROP INDEX [IX_JobTimeEntries_ShopWorkerId] ON [JobTimeEntries];
|
||||
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Jobs_ShopWorkerId' AND object_id = OBJECT_ID('Jobs'))
|
||||
DROP INDEX [IX_Jobs_ShopWorkerId] ON [Jobs];
|
||||
IF EXISTS (SELECT 1 FROM sys.columns WHERE name = 'ShopWorkerId' AND object_id = OBJECT_ID('MaintenanceRecords'))
|
||||
ALTER TABLE [MaintenanceRecords] DROP COLUMN [ShopWorkerId];
|
||||
IF EXISTS (SELECT 1 FROM sys.columns WHERE name = 'ShopWorkerId' AND object_id = OBJECT_ID('JobTimeEntries'))
|
||||
ALTER TABLE [JobTimeEntries] DROP COLUMN [ShopWorkerId];
|
||||
IF EXISTS (SELECT 1 FROM sys.columns WHERE name = 'ShopWorkerId' AND object_id = OBJECT_ID('Jobs'))
|
||||
ALTER TABLE [Jobs] DROP COLUMN [ShopWorkerId];
|
||||
");
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "ReminderSentAt",
|
||||
table: "Appointments",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 19, 15, 12, 57, 355, DateTimeKind.Utc).AddTicks(2970));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 19, 15, 12, 57, 355, DateTimeKind.Utc).AddTicks(2976));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 19, 15, 12, 57, 355, DateTimeKind.Utc).AddTicks(2977));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ReminderSentAt",
|
||||
table: "Appointments");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ShopWorkerId",
|
||||
table: "MaintenanceRecords",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ShopWorkerId",
|
||||
table: "JobTimeEntries",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ShopWorkerId",
|
||||
table: "Jobs",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ShopWorkerRoleCosts",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
HourlyRate = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
Role = table.Column<int>(type: "int", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ShopWorkerRoleCosts", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ShopWorkers",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Email = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Phone = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Role = table.Column<int>(type: "int", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ShopWorkers", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ShopWorkers_Companies_CompanyId",
|
||||
column: x => x.CompanyId,
|
||||
principalTable: "Companies",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3131));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3137));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3138));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MaintenanceRecords_ShopWorkerId",
|
||||
table: "MaintenanceRecords",
|
||||
column: "ShopWorkerId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_JobTimeEntries_ShopWorkerId",
|
||||
table: "JobTimeEntries",
|
||||
column: "ShopWorkerId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Jobs_ShopWorkerId",
|
||||
table: "Jobs",
|
||||
column: "ShopWorkerId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ShopWorkerRoleCosts_CompanyId_Role",
|
||||
table: "ShopWorkerRoleCosts",
|
||||
columns: new[] { "CompanyId", "Role" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ShopWorkers_CompanyId",
|
||||
table: "ShopWorkers",
|
||||
column: "CompanyId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Jobs_ShopWorkers_ShopWorkerId",
|
||||
table: "Jobs",
|
||||
column: "ShopWorkerId",
|
||||
principalTable: "ShopWorkers",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_JobTimeEntries_ShopWorkers_ShopWorkerId",
|
||||
table: "JobTimeEntries",
|
||||
column: "ShopWorkerId",
|
||||
principalTable: "ShopWorkers",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_MaintenanceRecords_ShopWorkers_ShopWorkerId",
|
||||
table: "MaintenanceRecords",
|
||||
column: "ShopWorkerId",
|
||||
principalTable: "ShopWorkers",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10639
File diff suppressed because it is too large
Load Diff
+83
@@ -0,0 +1,83 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddNoExtraLayerChargeToCoats : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "NoExtraLayerCharge",
|
||||
table: "QuoteItemCoats",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "NoExtraLayerCharge",
|
||||
table: "JobItemCoats",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5186));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5190));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5191));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NoExtraLayerCharge",
|
||||
table: "QuoteItemCoats");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NoExtraLayerCharge",
|
||||
table: "JobItemCoats");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 19, 15, 44, 18, 742, DateTimeKind.Utc).AddTicks(3960));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 19, 15, 44, 18, 742, DateTimeKind.Utc).AddTicks(3966));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 19, 15, 44, 18, 742, DateTimeKind.Utc).AddTicks(3967));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -716,6 +716,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<int>("ReminderMinutesBefore")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("ReminderSentAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime>("ScheduledEndTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
@@ -4252,9 +4255,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<decimal>("ShopSuppliesPercent")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int?>("ShopWorkerId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("SpecialInstructions")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -4296,8 +4296,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
|
||||
b.HasIndex("ScheduledDate");
|
||||
|
||||
b.HasIndex("ShopWorkerId");
|
||||
|
||||
b.HasIndex("CompanyId", "CustomerId")
|
||||
.HasDatabaseName("IX_Jobs_CompanyId_CustomerId");
|
||||
|
||||
@@ -4620,6 +4618,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<int>("JobItemId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool>("NoExtraLayerCharge")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -5439,9 +5440,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("ShopWorkerId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Stage")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -5464,8 +5462,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
|
||||
b.HasIndex("JobId");
|
||||
|
||||
b.HasIndex("ShopWorkerId");
|
||||
|
||||
b.ToTable("JobTimeEntries");
|
||||
});
|
||||
|
||||
@@ -5789,9 +5785,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<DateTime>("ScheduledDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int?>("ShopWorkerId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -5822,8 +5815,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
|
||||
b.HasIndex("ScheduledDate");
|
||||
|
||||
b.HasIndex("ShopWorkerId");
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.HasIndex("CompanyId", "ScheduledDate")
|
||||
@@ -6720,7 +6711,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3131),
|
||||
CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5186),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -6731,7 +6722,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3137),
|
||||
CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5190),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -6742,7 +6733,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3138),
|
||||
CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5191),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
@@ -7417,6 +7408,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("NoExtraLayerCharge")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -8019,111 +8013,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.ToTable("ReworkRecords");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorker", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeletedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CompanyId");
|
||||
|
||||
b.ToTable("ShopWorkers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorkerRoleCost", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeletedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<decimal>("HourlyRate")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CompanyId", "Role")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_ShopWorkerRoleCosts_CompanyId_Role");
|
||||
|
||||
b.ToTable("ShopWorkerRoleCosts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.StripeWebhookEvent", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
@@ -9541,10 +9430,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
.HasForeignKey("PowderCoating.Core.Entities.Job", "QuoteId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("PowderCoating.Core.Entities.ShopWorker", null)
|
||||
.WithMany("AssignedJobs")
|
||||
.HasForeignKey("ShopWorkerId");
|
||||
|
||||
b.Navigation("AssignedUser");
|
||||
|
||||
b.Navigation("Customer");
|
||||
@@ -9847,13 +9732,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("PowderCoating.Core.Entities.ShopWorker", "Worker")
|
||||
.WithMany("TimeEntries")
|
||||
.HasForeignKey("ShopWorkerId");
|
||||
|
||||
b.Navigation("Job");
|
||||
|
||||
b.Navigation("Worker");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.JournalEntry", b =>
|
||||
@@ -9924,10 +9803,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
.WithMany()
|
||||
.HasForeignKey("RecurrenceParentId");
|
||||
|
||||
b.HasOne("PowderCoating.Core.Entities.ShopWorker", null)
|
||||
.WithMany("AssignedMaintenanceTasks")
|
||||
.HasForeignKey("ShopWorkerId");
|
||||
|
||||
b.Navigation("AssignedUser");
|
||||
|
||||
b.Navigation("Equipment");
|
||||
@@ -10411,15 +10286,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Navigation("ReworkJob");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorker", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.Company", null)
|
||||
.WithMany("ShopWorkers")
|
||||
.HasForeignKey("CompanyId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.Vendor", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.Company", null)
|
||||
@@ -10582,8 +10448,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
|
||||
b.Navigation("Quotes");
|
||||
|
||||
b.Navigation("ShopWorkers");
|
||||
|
||||
b.Navigation("Users");
|
||||
|
||||
b.Navigation("Vendors");
|
||||
@@ -10749,15 +10613,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Navigation("Quotes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorker", b =>
|
||||
{
|
||||
b.Navigation("AssignedJobs");
|
||||
|
||||
b.Navigation("AssignedMaintenanceTasks");
|
||||
|
||||
b.Navigation("TimeEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.Vendor", b =>
|
||||
{
|
||||
b.Navigation("BillPayments");
|
||||
|
||||
@@ -1152,6 +1152,156 @@ public class NotificationService : INotificationService
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends appointment reminder emails when an appointment's reminder window opens.
|
||||
/// Two emails are dispatched independently:
|
||||
/// <list type="bullet">
|
||||
/// <item>Customer email — sent when a customer is linked, has an email address, and has
|
||||
/// email notifications enabled (<see cref="Customer.NotifyByEmail"/>).</item>
|
||||
/// <item>Staff email — sent to <see cref="BaseEntity.CreatedBy"/> (the user who created
|
||||
/// the appointment). This fires regardless of whether a customer is linked.</item>
|
||||
/// </list>
|
||||
/// Called exclusively by
|
||||
/// <see cref="PowderCoating.Web.BackgroundServices.AppointmentReminderBackgroundService"/>
|
||||
/// after it stamps <c>ReminderSentAt</c> — the caller owns deduplication.
|
||||
/// </summary>
|
||||
public async Task NotifyAppointmentReminderAsync(Appointment appointment)
|
||||
{
|
||||
try
|
||||
{
|
||||
var (companyName, company) = await GetCompanyAsync(appointment.CompanyId);
|
||||
var (replyToEmail, replyToName) = await GetEmailFromAsync(appointment.CompanyId);
|
||||
var baseUrl = await GetBaseUrlAsync();
|
||||
|
||||
var locationLine = !string.IsNullOrWhiteSpace(appointment.Location)
|
||||
? $"<br/><strong>Location:</strong> {WebUtility.HtmlEncode(appointment.Location)}"
|
||||
: string.Empty;
|
||||
|
||||
var appointmentDate = appointment.ScheduledStartTime.ToString("dddd, MMMM d, yyyy");
|
||||
var appointmentTime = appointment.IsAllDay
|
||||
? "All Day"
|
||||
: appointment.ScheduledStartTime.ToString("h:mm tt");
|
||||
var defaultSubject = $"Appointment Reminder — {appointment.Title} on {appointment.ScheduledStartTime:MMMM d, yyyy}";
|
||||
|
||||
// ── Customer email ────────────────────────────────────────────────
|
||||
if (appointment.CustomerId != null)
|
||||
{
|
||||
var customer = appointment.Customer
|
||||
?? await _context.Customers.FindAsync(appointment.CustomerId.Value);
|
||||
|
||||
if (customer != null)
|
||||
{
|
||||
var customerName = GetCustomerDisplayName(customer);
|
||||
var reminderEmails = ParseEmailList(customer.Email);
|
||||
|
||||
if (!customer.NotifyByEmail || reminderEmails.Count == 0)
|
||||
{
|
||||
if (reminderEmails.Count > 0)
|
||||
{
|
||||
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.AppointmentReminder,
|
||||
customerName, string.Join(", ", reminderEmails), appointment.CompanyId,
|
||||
customerId: customer.Id));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var customerValues = new Dictionary<string, string>
|
||||
{
|
||||
["companyName"] = companyName,
|
||||
["customerName"] = customerName,
|
||||
["appointmentTitle"] = appointment.Title,
|
||||
["appointmentDate"] = appointmentDate,
|
||||
["appointmentTime"] = appointmentTime,
|
||||
["locationLine"] = locationLine
|
||||
};
|
||||
|
||||
var (custSubject, custHtml) = await GetRenderedEmailAsync(
|
||||
appointment.CompanyId, NotificationType.AppointmentReminder, customerValues, defaultSubject);
|
||||
|
||||
var custFullHtml = AppendUnsubscribeFooterHtml(custHtml, customer.UnsubscribeToken, company, baseUrl);
|
||||
var custPlainText = StripHtml(custFullHtml);
|
||||
|
||||
var (custOk, custErr, custLog) = await SendToEmailListAsync(
|
||||
customer.Email, customerName, custSubject, custPlainText, custFullHtml,
|
||||
replyToEmail: replyToEmail, replyToName: replyToName);
|
||||
|
||||
await WriteLog(new NotificationLog
|
||||
{
|
||||
Channel = NotificationChannel.Email,
|
||||
NotificationType = NotificationType.AppointmentReminder,
|
||||
Status = custOk ? NotificationStatus.Sent : NotificationStatus.Failed,
|
||||
RecipientName = customerName,
|
||||
Recipient = custLog,
|
||||
Subject = custSubject,
|
||||
Message = custPlainText,
|
||||
ErrorMessage = custErr,
|
||||
SentAt = DateTime.UtcNow,
|
||||
CustomerId = customer.Id,
|
||||
CompanyId = appointment.CompanyId
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Staff email ───────────────────────────────────────────────────
|
||||
// Send to whoever created the appointment so they get an out-of-app reminder.
|
||||
if (!string.IsNullOrWhiteSpace(appointment.CreatedBy))
|
||||
{
|
||||
// Look up the user's display name from Identity if available.
|
||||
var staffUser = await _context.Users
|
||||
.FirstOrDefaultAsync(u => u.Email == appointment.CreatedBy);
|
||||
var staffName = !string.IsNullOrWhiteSpace(staffUser?.FullName)
|
||||
? staffUser.FullName
|
||||
: appointment.CreatedBy;
|
||||
|
||||
// Include a customer line only when a customer is linked.
|
||||
var customerLine = appointment.Customer != null
|
||||
? $"<br/><strong>Customer:</strong> {WebUtility.HtmlEncode(GetCustomerDisplayName(appointment.Customer))}"
|
||||
: string.Empty;
|
||||
|
||||
var staffValues = new Dictionary<string, string>
|
||||
{
|
||||
["companyName"] = companyName,
|
||||
["staffName"] = staffName,
|
||||
["appointmentTitle"] = appointment.Title,
|
||||
["appointmentDate"] = appointmentDate,
|
||||
["appointmentTime"] = appointmentTime,
|
||||
["customerLine"] = customerLine,
|
||||
["locationLine"] = locationLine
|
||||
};
|
||||
|
||||
var staffDefaultSubject = $"[Reminder] {appointment.Title} — {appointment.ScheduledStartTime:MMMM d, yyyy 'at' h:mm tt}";
|
||||
|
||||
var (staffSubject, staffHtml) = await GetRenderedEmailAsync(
|
||||
appointment.CompanyId, NotificationType.AppointmentReminderStaff, staffValues, staffDefaultSubject);
|
||||
|
||||
var staffPlainText = StripHtml(staffHtml);
|
||||
|
||||
var (staffOk, staffErr, staffLog) = await SendToEmailListAsync(
|
||||
appointment.CreatedBy, staffName, staffSubject, staffPlainText, staffHtml,
|
||||
replyToEmail: replyToEmail, replyToName: replyToName);
|
||||
|
||||
await WriteLog(new NotificationLog
|
||||
{
|
||||
Channel = NotificationChannel.Email,
|
||||
NotificationType = NotificationType.AppointmentReminderStaff,
|
||||
Status = staffOk ? NotificationStatus.Sent : NotificationStatus.Failed,
|
||||
RecipientName = staffName,
|
||||
Recipient = staffLog,
|
||||
Subject = staffSubject,
|
||||
Message = staffPlainText,
|
||||
ErrorMessage = staffErr,
|
||||
SentAt = DateTime.UtcNow,
|
||||
CompanyId = appointment.CompanyId
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "NotifyAppointmentReminderAsync failed for appointment {AppointmentId}", appointment.Id);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Fallback default templates (used when company has no DB template)
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -1217,6 +1367,14 @@ public class NotificationService : INotificationService
|
||||
"Payment Reminder — Invoice {{invoiceNumber}} ({{daysOverdue}} days overdue)",
|
||||
"<p>Dear {{customerName}},</p><p>This is a friendly reminder that invoice <strong>{{invoiceNumber}}</strong> for <strong>{{invoiceTotal}}</strong> was due on <strong>{{dueDate}}</strong> and is now <strong>{{daysOverdue}} days overdue</strong>.</p><p>Outstanding balance: <strong>{{balanceDue}}</strong></p><p>Please arrange payment at your earliest convenience. If you have already sent payment, please disregard this notice.</p><p>Thank you for your business with {{companyName}}.</p>"
|
||||
),
|
||||
[(NotificationType.AppointmentReminder, NotificationChannel.Email)] = (
|
||||
"Appointment Reminder — {{appointmentTitle}} on {{appointmentDate}}",
|
||||
"<p>Dear {{customerName}},</p><p>This is a reminder that you have an upcoming appointment with <strong>{{companyName}}</strong>.</p><p><strong>Appointment:</strong> {{appointmentTitle}}<br/><strong>Date & Time:</strong> {{appointmentDate}} at {{appointmentTime}}{{locationLine}}</p><p>If you have any questions or need to reschedule, please contact us at your earliest convenience.</p><p>Thank you for choosing {{companyName}}.</p>"
|
||||
),
|
||||
[(NotificationType.AppointmentReminderStaff, NotificationChannel.Email)] = (
|
||||
"[Reminder] {{appointmentTitle}} — {{appointmentDate}}",
|
||||
"<p>Hi {{staffName}},</p><p>This is a reminder that you have an upcoming appointment.</p><p><strong>Appointment:</strong> {{appointmentTitle}}<br/><strong>Date & Time:</strong> {{appointmentDate}} at {{appointmentTime}}{{customerLine}}{{locationLine}}</p><p>— {{companyName}}</p>"
|
||||
),
|
||||
};
|
||||
|
||||
public static (string? Subject, string Body)? Get(NotificationType type, NotificationChannel channel)
|
||||
|
||||
@@ -162,7 +162,6 @@ public class StripeConnectService : IStripeConnectService
|
||||
decimal invoiceTotal,
|
||||
decimal surchargeAmount,
|
||||
string currency,
|
||||
string customerEmail,
|
||||
string invoiceNumber,
|
||||
int invoiceId)
|
||||
{
|
||||
@@ -175,7 +174,6 @@ public class StripeConnectService : IStripeConnectService
|
||||
{
|
||||
Amount = amountInCents,
|
||||
Currency = currency.ToLower(),
|
||||
ReceiptEmail = customerEmail,
|
||||
Description = $"Invoice {invoiceNumber}",
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
@@ -215,7 +213,6 @@ public class StripeConnectService : IStripeConnectService
|
||||
decimal depositAmount,
|
||||
decimal surchargeAmount,
|
||||
string currency,
|
||||
string customerEmail,
|
||||
string quoteNumber,
|
||||
int quoteId)
|
||||
{
|
||||
@@ -228,7 +225,6 @@ public class StripeConnectService : IStripeConnectService
|
||||
{
|
||||
Amount = amountInCents,
|
||||
Currency = currency.ToLower(),
|
||||
ReceiptEmail = customerEmail,
|
||||
Description = $"Deposit for quote {quoteNumber}",
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Web.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// Polls every 60 seconds for appointments whose reminder window has opened and dispatches
|
||||
/// an email to the linked customer plus an in-app bell notification to company staff.
|
||||
///
|
||||
/// Deduplication strategy: after selecting candidates the service immediately stamps
|
||||
/// <c>ReminderSentAt</c> on each appointment and saves before calling the notification
|
||||
/// methods. This prevents a second loop iteration from re-sending if notifications are slow
|
||||
/// or the application restarts mid-batch. A 24-hour lookback window caps the query so that
|
||||
/// appointments that slipped through (e.g., server downtime) are silently skipped rather
|
||||
/// than sending a stale reminder.
|
||||
/// </summary>
|
||||
public class AppointmentReminderBackgroundService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<AppointmentReminderBackgroundService> _logger;
|
||||
|
||||
private static readonly TimeSpan PollingInterval = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Appointments whose scheduled start is more than this far in the past are ignored even
|
||||
/// if their reminder was never sent (server was down, etc.). We do not want to blast a
|
||||
/// customer with a "your appointment is in 30 minutes" email hours after it was due.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan MaxLookback = TimeSpan.FromHours(24);
|
||||
|
||||
public AppointmentReminderBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<AppointmentReminderBackgroundService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Long-running loop that wakes every <see cref="PollingInterval"/> (60 s) and calls
|
||||
/// <see cref="RunAsync"/>. Uses <see cref="Task.Delay"/> with the cancellation token so
|
||||
/// the service shuts down promptly when the application stops.
|
||||
/// </summary>
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("AppointmentReminderBackgroundService started.");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(PollingInterval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (stoppingToken.IsCancellationRequested) break;
|
||||
|
||||
await RunAsync(stoppingToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("AppointmentReminderBackgroundService stopped.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One poll iteration: find all appointments whose reminder window has opened, stamp them,
|
||||
/// then dispatch email + in-app notifications. A fresh DI scope is created per poll so that
|
||||
/// the DbContext change tracker is clean each time.
|
||||
/// </summary>
|
||||
private async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var notificationService = scope.ServiceProvider.GetRequiredService<INotificationService>();
|
||||
var inAppService = scope.ServiceProvider.GetRequiredService<IInAppNotificationService>();
|
||||
|
||||
// ScheduledStartTime is stored as server-local time (no UTC conversion on form submit),
|
||||
// so compare against DateTime.Now rather than UtcNow to avoid a 4-hour EDT offset.
|
||||
var now = DateTime.Now;
|
||||
var lookback = now - MaxLookback;
|
||||
|
||||
// Find appointments where:
|
||||
// - Reminder is enabled and has not been sent yet
|
||||
// - The reminder window has opened: ScheduledStartTime - ReminderMinutesBefore <= now
|
||||
// - The appointment hasn't been sitting unprocessed for more than MaxLookback
|
||||
// - The appointment status is not terminal (not cancelled, completed, no-show, etc.)
|
||||
// IgnoreQueryFilters bypasses the tenant filter — no HTTP context in a background service.
|
||||
var candidates = await db.Appointments
|
||||
.IgnoreQueryFilters()
|
||||
.Include(a => a.Customer)
|
||||
.Include(a => a.AppointmentStatus)
|
||||
.Where(a =>
|
||||
!a.IsDeleted &&
|
||||
a.IsReminderEnabled &&
|
||||
a.ReminderSentAt == null &&
|
||||
a.ScheduledStartTime > lookback &&
|
||||
EF.Functions.DateDiffMinute(now, a.ScheduledStartTime) <= a.ReminderMinutesBefore &&
|
||||
!a.AppointmentStatus.IsTerminalStatus)
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (candidates.Count == 0) return;
|
||||
|
||||
_logger.LogInformation(
|
||||
"AppointmentReminderBackgroundService: {Count} appointment reminder(s) to dispatch.",
|
||||
candidates.Count);
|
||||
|
||||
// Stamp ReminderSentAt before sending — prevents a restart from re-sending.
|
||||
var stampedAt = now;
|
||||
foreach (var appt in candidates)
|
||||
appt.ReminderSentAt = stampedAt;
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
// Now send notifications. Failures here don't roll back the stamp because we'd
|
||||
// rather skip one reminder than spam a customer on every restart.
|
||||
foreach (var appt in candidates)
|
||||
{
|
||||
if (ct.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
// Email to linked customer (no-ops internally if customer has opted out)
|
||||
await notificationService.NotifyAppointmentReminderAsync(appt);
|
||||
|
||||
// In-app bell notification for company staff
|
||||
var when = appt.IsAllDay
|
||||
? appt.ScheduledStartTime.ToString("MMMM d, yyyy")
|
||||
: appt.ScheduledStartTime.ToString("MMMM d, yyyy 'at' h:mm tt");
|
||||
|
||||
await inAppService.CreateAsync(
|
||||
companyId: appt.CompanyId,
|
||||
title: $"Appointment Reminder: {appt.Title}",
|
||||
message: $"{appt.AppointmentNumber} is scheduled for {when}.",
|
||||
notificationType: "AppointmentReminder",
|
||||
link: $"/Appointments/Details/{appt.Id}",
|
||||
customerId: appt.CustomerId);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Reminder dispatched for appointment {AppointmentNumber} (id {Id}, company {CompanyId}).",
|
||||
appt.AppointmentNumber, appt.Id, appt.CompanyId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Failed to dispatch reminder for appointment {AppointmentId}.", appt.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "AppointmentReminderBackgroundService poll failed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -381,9 +381,15 @@ public class AppointmentsController : Controller
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
// Map changes
|
||||
// Map changes — capture old start before overwrite so we can detect a reschedule.
|
||||
var previousStart = appointment.ScheduledStartTime;
|
||||
_mapper.Map(dto, appointment);
|
||||
|
||||
// If the appointment was rescheduled, clear the reminder stamp so the background
|
||||
// service will fire again at the new time.
|
||||
if (appointment.ScheduledStartTime != previousStart)
|
||||
appointment.ReminderSentAt = null;
|
||||
|
||||
// Update
|
||||
await _unitOfWork.Appointments.UpdateAsync(appointment);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
@@ -725,6 +731,8 @@ public class AppointmentsController : Controller
|
||||
var duration = appointment.ScheduledEndTime - appointment.ScheduledStartTime;
|
||||
appointment.ScheduledStartTime = start;
|
||||
appointment.ScheduledEndTime = end;
|
||||
// Drag-drop always changes the time — reset so the reminder fires at the new time.
|
||||
appointment.ReminderSentAt = null;
|
||||
|
||||
await _unitOfWork.Appointments.UpdateAsync(appointment);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
@@ -355,6 +355,15 @@ public class InvoicesController : Controller
|
||||
var job = await _unitOfWork.Jobs.GetByIdAsync(jobId.Value, false, j => j.Customer, j => j.JobItems);
|
||||
if (job == null) return NotFound();
|
||||
|
||||
// Pre-load coats so we can derive color names for invoice line items
|
||||
var activeItemIds = job.JobItems.Where(ji => !ji.IsDeleted).Select(ji => ji.Id).ToList();
|
||||
var allCoats = activeItemIds.Any()
|
||||
? (await _unitOfWork.JobItemCoats.FindAsync(c => activeItemIds.Contains(c.JobItemId) && !c.IsDeleted)).ToList()
|
||||
: new List<JobItemCoat>();
|
||||
var coatsByItem = allCoats
|
||||
.GroupBy(c => c.JobItemId)
|
||||
.ToDictionary(g => g.Key, g => g.OrderBy(c => c.Sequence).ToList());
|
||||
|
||||
// Validate no existing active invoice for this job (voided ones are kept as history)
|
||||
var existing = await _unitOfWork.Invoices.GetForJobAsync(jobId.Value);
|
||||
if (existing != null && existing.Status != InvoiceStatus.Voided)
|
||||
@@ -404,6 +413,16 @@ public class InvoicesController : Controller
|
||||
revenueAccountId = ci.RevenueAccountId;
|
||||
revenueAccountId ??= defaultRevenueAccount?.Id;
|
||||
|
||||
// Derive color from coats when the item itself has no explicit color set
|
||||
var derivedColor = item.ColorName;
|
||||
if (string.IsNullOrEmpty(derivedColor) && coatsByItem.TryGetValue(item.Id, out var itemCoats))
|
||||
{
|
||||
var coatColors = itemCoats
|
||||
.Where(c => !string.IsNullOrEmpty(c.ColorName))
|
||||
.Select(c => c.ColorName!);
|
||||
derivedColor = string.Join(" / ", coatColors);
|
||||
}
|
||||
|
||||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||
{
|
||||
SourceJobItemId = item.Id,
|
||||
@@ -412,7 +431,7 @@ public class InvoicesController : Controller
|
||||
Quantity = item.Quantity > 0 ? item.Quantity : 1,
|
||||
UnitPrice = item.UnitPrice,
|
||||
TotalPrice = item.TotalPrice,
|
||||
ColorName = item.ColorName,
|
||||
ColorName = derivedColor,
|
||||
Notes = item.Notes,
|
||||
DisplayOrder = order++,
|
||||
RevenueAccountId = revenueAccountId
|
||||
@@ -1461,6 +1480,7 @@ public class InvoicesController : Controller
|
||||
}
|
||||
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
var totalCreditCreated = 0m; // populated inside transaction, used in success message
|
||||
|
||||
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
||||
{
|
||||
@@ -1480,6 +1500,75 @@ public class InvoicesController : Controller
|
||||
await _unitOfWork.Payments.SoftDeleteAsync(payment.Id);
|
||||
}
|
||||
|
||||
// Re-release any deposits that were applied to this invoice so they can be
|
||||
// auto-applied to the replacement invoice. Without this, AppliedToInvoiceId
|
||||
// stays set and the deposit lookup (AppliedToInvoiceId == null) skips them.
|
||||
var appliedDeposits = await _unitOfWork.Deposits.FindAsync(
|
||||
d => d.AppliedToInvoiceId == invoice.Id && !d.IsDeleted);
|
||||
var totalDepositReleased = 0m;
|
||||
foreach (var deposit in appliedDeposits)
|
||||
{
|
||||
deposit.AppliedToInvoiceId = null;
|
||||
deposit.AppliedDate = null;
|
||||
deposit.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.Deposits.UpdateAsync(deposit);
|
||||
totalDepositReleased += deposit.Amount;
|
||||
}
|
||||
// Restore the CustomerDeposits 2300 liability that was cleared when the deposits
|
||||
// were applied. Mirrors the DR at apply time; follows the same simplified reversal
|
||||
// pattern as the rest of the void (regular payment GL entries are also left as-is).
|
||||
if (totalDepositReleased > 0)
|
||||
{
|
||||
var custDepositsAcctId = await GetCustomerDepositsAccountIdAsync(invoice.CompanyId);
|
||||
await _accountBalanceService.CreditAsync(custDepositsAcctId, totalDepositReleased);
|
||||
}
|
||||
|
||||
// Convert non-deposit payments (cash, card, check, online) to customer credits so
|
||||
// the money isn't lost when the invoice is voided. Each payment becomes a CRED-
|
||||
// Deposit record linked to the same job; it will auto-apply when the replacement
|
||||
// invoice is created, exactly like a normal deposit.
|
||||
var nonDepositPayments = invoice.Payments
|
||||
.Where(p => !p.IsDeleted && !(p.Reference ?? "").StartsWith("Deposit "))
|
||||
.ToList();
|
||||
if (nonDepositPayments.Any())
|
||||
{
|
||||
var credPrefix = $"CRED-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-";
|
||||
var existingNums = (await _unitOfWork.Deposits.FindAsync(
|
||||
d => d.CompanyId == invoice.CompanyId && d.ReceiptNumber.StartsWith(credPrefix),
|
||||
ignoreQueryFilters: true))
|
||||
.Select(d => d.ReceiptNumber).ToList();
|
||||
var maxNum = 0;
|
||||
foreach (var rn in existingNums)
|
||||
{
|
||||
var suffix = rn.Length >= credPrefix.Length + 4 ? rn[credPrefix.Length..] : "";
|
||||
if (int.TryParse(suffix, out int parsed) && parsed > maxNum) maxNum = parsed;
|
||||
}
|
||||
|
||||
var creditCustDepositsAcctId = await GetCustomerDepositsAccountIdAsync(invoice.CompanyId);
|
||||
foreach (var payment in nonDepositPayments)
|
||||
{
|
||||
maxNum++;
|
||||
await _unitOfWork.Deposits.AddAsync(new Core.Entities.Deposit
|
||||
{
|
||||
CompanyId = invoice.CompanyId,
|
||||
CustomerId = invoice.CustomerId,
|
||||
JobId = invoice.JobId,
|
||||
Amount = payment.Amount,
|
||||
PaymentMethod = payment.PaymentMethod,
|
||||
ReceivedDate = payment.PaymentDate,
|
||||
Reference = payment.Reference,
|
||||
Notes = $"Credit from voided invoice {invoice.InvoiceNumber}" +
|
||||
(string.IsNullOrWhiteSpace(payment.Notes) ? "." : $". Original: {payment.Notes}"),
|
||||
ReceiptNumber = $"{credPrefix}{maxNum:D4}",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
totalCreditCreated += payment.Amount;
|
||||
}
|
||||
|
||||
// CR CustomerDeposits to create the liability matching the cash already in Checking
|
||||
await _accountBalanceService.CreditAsync(creditCustDepositsAcctId, totalCreditCreated);
|
||||
}
|
||||
|
||||
// Void any gift certificates that were generated from this invoice.
|
||||
// Capture each GC's remaining balance BEFORE voiding so the GL entries below can use it.
|
||||
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(invoice.CompanyId);
|
||||
@@ -1531,7 +1620,10 @@ public class InvoicesController : Controller
|
||||
|
||||
}); // end ExecuteInTransactionAsync
|
||||
|
||||
TempData["Success"] = $"Invoice {invoice.InvoiceNumber} has been voided.";
|
||||
var creditMsg = totalCreditCreated > 0
|
||||
? $" {totalCreditCreated:C} converted to customer credit and will auto-apply to the next invoice."
|
||||
: "";
|
||||
TempData["Success"] = $"Invoice {invoice.InvoiceNumber} has been voided.{creditMsg}";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -3016,6 +3108,50 @@ public class InvoicesController : Controller
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inline-edits description, quantity, and unit price on a single invoice line item.
|
||||
/// Blocked on paid/voided invoices (same gate as the full Edit action).
|
||||
/// Returns updated totals so the page can reflect the change without a reload.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> PatchItem([FromBody] PatchInvoiceItemRequest request)
|
||||
{
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
if (currentUser == null) return Unauthorized();
|
||||
|
||||
var item = await _unitOfWork.InvoiceItems.GetByIdAsync(request.ItemId);
|
||||
if (item == null) return NotFound();
|
||||
|
||||
var invoice = await _unitOfWork.Invoices.GetByIdAsync(item.InvoiceId);
|
||||
if (invoice == null || invoice.CompanyId != currentUser.CompanyId) return NotFound();
|
||||
|
||||
if (invoice.Status is not (InvoiceStatus.Draft or InvoiceStatus.Sent or InvoiceStatus.Overdue))
|
||||
return BadRequest(new { error = "Cannot edit items on a paid or voided invoice." });
|
||||
|
||||
item.Description = request.Description.Trim();
|
||||
item.Quantity = request.Quantity;
|
||||
item.UnitPrice = request.UnitPrice;
|
||||
item.TotalPrice = Math.Round(request.Quantity * request.UnitPrice, 2);
|
||||
await _unitOfWork.InvoiceItems.UpdateAsync(item);
|
||||
|
||||
var allItems = await _unitOfWork.InvoiceItems.FindAsync(ii => ii.InvoiceId == invoice.Id);
|
||||
var newSubTotal = allItems.Sum(i => i.TotalPrice);
|
||||
invoice.SubTotal = newSubTotal;
|
||||
invoice.TaxAmount = Math.Round(newSubTotal * invoice.TaxPercent / 100m, 2);
|
||||
invoice.Total = Math.Round(newSubTotal - invoice.DiscountAmount + invoice.TaxAmount, 2);
|
||||
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return Json(new {
|
||||
lineTotal = item.TotalPrice,
|
||||
subtotal = invoice.SubTotal,
|
||||
taxAmount = invoice.TaxAmount,
|
||||
total = invoice.Total,
|
||||
balanceDue = invoice.BalanceDue
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns logo bytes and content type for PDF generation.
|
||||
/// Prefers blob-stored logos (LogoFilePath) over the legacy DB column (LogoData).
|
||||
@@ -3031,3 +3167,11 @@ public class InvoicesController : Controller
|
||||
return (company.LogoData, company.LogoContentType);
|
||||
}
|
||||
}
|
||||
|
||||
public class PatchInvoiceItemRequest
|
||||
{
|
||||
public int ItemId { get; set; }
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public decimal Quantity { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
}
|
||||
|
||||
@@ -110,6 +110,11 @@ public class JobsController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
// Default landing view: On Floor — redirect bare /Jobs to ?statusGroup=active
|
||||
// so completed/cancelled jobs don't clutter the first screen.
|
||||
if (string.IsNullOrEmpty(statusGroup) && string.IsNullOrEmpty(searchTerm) && string.IsNullOrEmpty(tagFilter))
|
||||
return RedirectToAction("Index", new { statusGroup = "active" });
|
||||
|
||||
// Create and validate grid request
|
||||
var gridRequest = new GridRequest
|
||||
{
|
||||
@@ -141,6 +146,13 @@ public class JobsController : Controller
|
||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
|
||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled;
|
||||
}
|
||||
else if (statusGroup == "completed")
|
||||
{
|
||||
filter = j => j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Completed
|
||||
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup
|
||||
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Delivered;
|
||||
}
|
||||
// "all" or unknown group: no filter applied (show every status)
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
{
|
||||
@@ -195,6 +207,27 @@ public class JobsController : Controller
|
||||
gridRequest, jobDtos,
|
||||
string.IsNullOrWhiteSpace(tagFilter) ? totalCount : jobDtos.Count);
|
||||
|
||||
// Pill badge counts — always global (not scoped to current filter/page)
|
||||
var today = DateTime.Today;
|
||||
ViewBag.AllJobCount = await _unitOfWork.Jobs.CountAsync();
|
||||
ViewBag.ActiveCount = await _unitOfWork.Jobs.CountAsync(j =>
|
||||
j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed
|
||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup
|
||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
|
||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled);
|
||||
ViewBag.OverdueCount = await _unitOfWork.Jobs.CountAsync(j =>
|
||||
j.DueDate < today
|
||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed
|
||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup
|
||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
|
||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled);
|
||||
ViewBag.CompletedCount = await _unitOfWork.Jobs.CountAsync(j =>
|
||||
j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Completed
|
||||
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup
|
||||
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Delivered);
|
||||
ViewBag.ReadyCount = await _unitOfWork.Jobs.CountAsync(j =>
|
||||
j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup);
|
||||
|
||||
// Set ViewBag for sorting
|
||||
ViewBag.SearchTerm = searchTerm;
|
||||
ViewBag.StatusGroup = statusGroup;
|
||||
@@ -477,6 +510,7 @@ public class JobsController : Controller
|
||||
transferEfficiency = c.TransferEfficiency,
|
||||
powderCostPerLb = c.PowderCostPerLb,
|
||||
powderToOrder = c.PowderToOrder,
|
||||
noExtraLayerCharge = c.NoExtraLayerCharge,
|
||||
notes = c.Notes
|
||||
}),
|
||||
prepServices = ji.PrepServices.Select(ps => new {
|
||||
@@ -1078,6 +1112,8 @@ public class JobsController : Controller
|
||||
QuoteId = dto.QuoteId,
|
||||
AssignedUserId = dto.AssignedUserId,
|
||||
OvenCostId = dto.OvenCostId,
|
||||
OvenBatches = dto.OvenBatches > 0 ? dto.OvenBatches : 1,
|
||||
OvenCycleMinutes = dto.OvenCycleMinutes,
|
||||
Description = dto.Description,
|
||||
JobPriorityId = dto.JobPriorityId,
|
||||
JobStatusId = pendingStatus?.Id ?? 1,
|
||||
@@ -1149,7 +1185,7 @@ public class JobsController : Controller
|
||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||
dto.JobItems, companyId, dto.CustomerId,
|
||||
await GetEffectiveTaxPercentAsync(dto.CustomerId, createCosts?.TaxPercent ?? 0m),
|
||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, createOvenRate, job.OvenBatches, job.OvenCycleMinutes);
|
||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, createOvenRate, dto.OvenBatches > 0 ? dto.OvenBatches : 1, dto.OvenCycleMinutes);
|
||||
|
||||
job.FinalPrice = totals.Total;
|
||||
job.OvenBatchCost = totals.OvenBatchCost;
|
||||
@@ -1217,6 +1253,9 @@ public class JobsController : Controller
|
||||
CustomerId = job.CustomerId,
|
||||
QuoteId = job.QuoteId,
|
||||
AssignedUserId = job.AssignedUserId,
|
||||
OvenCostId = job.OvenCostId,
|
||||
OvenBatches = job.OvenBatches > 0 ? job.OvenBatches : 1,
|
||||
OvenCycleMinutes = job.OvenCycleMinutes,
|
||||
Description = job.Description,
|
||||
JobStatusId = job.JobStatusId,
|
||||
JobPriorityId = job.JobPriorityId,
|
||||
@@ -1261,6 +1300,7 @@ public class JobsController : Controller
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb,
|
||||
PowderToOrder = c.PowderToOrder,
|
||||
NoExtraLayerCharge = c.NoExtraLayerCharge,
|
||||
Notes = c.Notes
|
||||
}).ToList(),
|
||||
PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto
|
||||
@@ -1391,6 +1431,9 @@ public class JobsController : Controller
|
||||
job.CustomerId = dto.CustomerId;
|
||||
job.QuoteId = dto.QuoteId;
|
||||
job.Description = dto.Description;
|
||||
job.OvenCostId = dto.OvenCostId;
|
||||
job.OvenBatches = dto.OvenBatches > 0 ? dto.OvenBatches : 1;
|
||||
job.OvenCycleMinutes = dto.OvenCycleMinutes;
|
||||
await RecordStatusChangeAsync(job, dto.JobStatusId);
|
||||
job.JobStatusId = dto.JobStatusId;
|
||||
job.JobPriorityId = dto.JobPriorityId;
|
||||
@@ -1617,7 +1660,7 @@ public class JobsController : Controller
|
||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||
dto.JobItems, companyId, dto.CustomerId,
|
||||
await GetEffectiveTaxPercentAsync(dto.CustomerId, editCosts?.TaxPercent ?? 0m),
|
||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, editOvenRate, job.OvenBatches, job.OvenCycleMinutes);
|
||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, editOvenRate, dto.OvenBatches > 0 ? dto.OvenBatches : 1, dto.OvenCycleMinutes);
|
||||
job.FinalPrice = totals.Total;
|
||||
job.OvenBatchCost = totals.OvenBatchCost;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
@@ -1818,6 +1861,7 @@ public class JobsController : Controller
|
||||
ViewBag.ComplexityModeratePercent = costs?.ComplexityModeratePercent ?? 5m;
|
||||
ViewBag.ComplexityComplexPercent = costs?.ComplexityComplexPercent ?? 15m;
|
||||
ViewBag.ComplexityExtremePercent = costs?.ComplexityExtremePercent ?? 25m;
|
||||
ViewBag.DefaultOvenCycleMinutes = costs?.DefaultOvenCycleMinutes ?? 45;
|
||||
var useMetric = await _tenantContext.UseMetricSystemAsync();
|
||||
ViewBag.UseMetric = useMetric;
|
||||
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
|
||||
@@ -2936,6 +2980,7 @@ public class JobsController : Controller
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb,
|
||||
PowderToOrder = c.PowderToOrder,
|
||||
NoExtraLayerCharge = c.NoExtraLayerCharge,
|
||||
Notes = c.Notes
|
||||
}).ToList(),
|
||||
PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto
|
||||
@@ -3116,7 +3161,8 @@ public class JobsController : Controller
|
||||
InventoryItemId = c.InventoryItemId,
|
||||
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb
|
||||
PowderCostPerLb = c.PowderCostPerLb,
|
||||
NoExtraLayerCharge = c.NoExtraLayerCharge
|
||||
}).ToList()
|
||||
}).ToList();
|
||||
|
||||
@@ -4170,9 +4216,69 @@ public class JobsController : Controller
|
||||
return Json(new { success = false, message = "An error occurred. Please try again." });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inline-edits description, quantity, and unit price on a single job line item.
|
||||
/// Adjusts FinalPrice and the stored PricingBreakdownJson snapshot by the price delta.
|
||||
/// Returns updated totals so the page can reflect the change without a reload.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> PatchItem([FromBody] PatchJobItemRequest request)
|
||||
{
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
if (currentUser == null) return Unauthorized();
|
||||
|
||||
var item = await _unitOfWork.JobItems.GetByIdAsync(request.ItemId);
|
||||
if (item == null) return NotFound();
|
||||
|
||||
var job = await _unitOfWork.Jobs.GetByIdAsync(item.JobId);
|
||||
if (job == null || job.CompanyId != currentUser.CompanyId) return NotFound();
|
||||
|
||||
var oldTotal = item.TotalPrice;
|
||||
item.Description = request.Description.Trim();
|
||||
item.Quantity = request.Quantity;
|
||||
item.UnitPrice = request.UnitPrice;
|
||||
item.TotalPrice = Math.Round(request.Quantity * request.UnitPrice, 2);
|
||||
await _unitOfWork.JobItems.UpdateAsync(item);
|
||||
|
||||
var delta = item.TotalPrice - oldTotal;
|
||||
job.FinalPrice = Math.Round(job.FinalPrice + delta, 2);
|
||||
|
||||
// Keep the stored pricing snapshot in sync so the breakdown panel stays consistent
|
||||
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
|
||||
{
|
||||
var pb = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
|
||||
if (pb != null)
|
||||
{
|
||||
pb.ItemsSubtotal += delta;
|
||||
pb.SubtotalBeforeDiscount += delta;
|
||||
pb.SubtotalAfterDiscount = pb.SubtotalBeforeDiscount - pb.DiscountAmount;
|
||||
pb.TaxAmount = Math.Round(pb.SubtotalAfterDiscount * pb.TaxPercent / 100m, 2);
|
||||
pb.Total = Math.Round(pb.SubtotalAfterDiscount + pb.RushFee + pb.TaxAmount, 2);
|
||||
job.FinalPrice = pb.Total;
|
||||
job.PricingBreakdownJson = JsonSerializer.Serialize(pb);
|
||||
}
|
||||
}
|
||||
|
||||
await _unitOfWork.Jobs.UpdateAsync(job);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return Json(new {
|
||||
lineTotal = item.TotalPrice,
|
||||
finalPrice = job.FinalPrice
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public class DeleteTimeEntryRequest { public int Id { get; set; } }
|
||||
public class PatchJobItemRequest
|
||||
{
|
||||
public int ItemId { get; set; }
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public decimal Quantity { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
}
|
||||
public class LogMaterialRequest
|
||||
{
|
||||
public int JobId { get; set; }
|
||||
|
||||
@@ -75,7 +75,6 @@ public class PaymentController : Controller
|
||||
CustomerName = customer != null
|
||||
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
|
||||
: "Valued Customer",
|
||||
CustomerEmail = customer?.Email ?? string.Empty,
|
||||
CompanyName = company!.CompanyName,
|
||||
BalanceDue = invoice.BalanceDue,
|
||||
InvoiceTotal = invoice.Total,
|
||||
@@ -127,8 +126,6 @@ public class PaymentController : Controller
|
||||
return BadRequest(new { error = "Invalid payment amount." });
|
||||
|
||||
var surcharge = CalculateSurcharge(request.Amount, company!);
|
||||
var customer = await _context.Customers.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == invoice.CustomerId);
|
||||
|
||||
var (success, clientSecret, paymentIntentId, stripeError) =
|
||||
await _stripeConnect.CreatePaymentIntentAsync(
|
||||
@@ -136,7 +133,6 @@ public class PaymentController : Controller
|
||||
invoiceTotal: request.Amount,
|
||||
surchargeAmount: surcharge,
|
||||
currency: "usd",
|
||||
customerEmail: customer?.Email ?? string.Empty,
|
||||
invoiceNumber: invoice.InvoiceNumber,
|
||||
invoiceId: invoice.Id);
|
||||
|
||||
@@ -261,7 +257,6 @@ public class PaymentController : Controller
|
||||
CustomerName = customer != null
|
||||
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
|
||||
: quote.ProspectContactName ?? "Valued Customer",
|
||||
CustomerEmail = customer?.Email ?? quote.ProspectEmail ?? string.Empty,
|
||||
CompanyName = company!.CompanyName,
|
||||
DepositAmount = depositAmount,
|
||||
QuoteTotal = quote.Total,
|
||||
@@ -296,7 +291,6 @@ public class PaymentController : Controller
|
||||
|
||||
var depositAmount = Math.Round(quote!.Total * (quote.DepositPercent / 100m), 2);
|
||||
var surcharge = CalculateSurcharge(depositAmount, company!);
|
||||
var customerEmail = quote.Customer?.Email ?? quote.ProspectEmail ?? string.Empty;
|
||||
|
||||
var (success, clientSecret, paymentIntentId, stripeError) =
|
||||
await _stripeConnect.CreateDepositPaymentIntentAsync(
|
||||
@@ -304,7 +298,6 @@ public class PaymentController : Controller
|
||||
depositAmount: depositAmount,
|
||||
surchargeAmount: surcharge,
|
||||
currency: "usd",
|
||||
customerEmail: customerEmail,
|
||||
quoteNumber: quote.QuoteNumber,
|
||||
quoteId: quote.Id);
|
||||
|
||||
@@ -942,7 +935,6 @@ public class PaymentPageViewModel
|
||||
public int InvoiceId { get; set; }
|
||||
public string Token { get; set; } = string.Empty;
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public string CustomerEmail { get; set; } = string.Empty;
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public decimal BalanceDue { get; set; }
|
||||
public decimal InvoiceTotal { get; set; }
|
||||
@@ -963,7 +955,6 @@ public class DepositPaymentPageViewModel
|
||||
public int QuoteId { get; set; }
|
||||
public string Token { get; set; } = string.Empty;
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public string CustomerEmail { get; set; } = string.Empty;
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public decimal DepositAmount { get; set; }
|
||||
public decimal QuoteTotal { get; set; }
|
||||
|
||||
@@ -3824,6 +3824,49 @@ public class QuotesController : Controller
|
||||
}
|
||||
return (company.LogoData, company.LogoContentType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inline-edits description, quantity, and unit price on a single quote line item.
|
||||
/// Adjusts stored quote totals by the price delta so the sidebar stays accurate.
|
||||
/// Returns updated totals so the page can reflect the change without a reload.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> PatchItem([FromBody] PatchQuoteItemRequest request)
|
||||
{
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
if (currentUser == null) return Unauthorized();
|
||||
|
||||
var item = await _unitOfWork.QuoteItems.GetByIdAsync(request.ItemId);
|
||||
if (item == null) return NotFound();
|
||||
|
||||
var quote = await _unitOfWork.Quotes.GetByIdAsync(item.QuoteId);
|
||||
if (quote == null || quote.CompanyId != currentUser.CompanyId) return NotFound();
|
||||
|
||||
var oldTotal = item.TotalPrice;
|
||||
item.Description = request.Description.Trim();
|
||||
item.Quantity = request.Quantity;
|
||||
item.UnitPrice = request.UnitPrice;
|
||||
item.TotalPrice = Math.Round(request.Quantity * request.UnitPrice, 2);
|
||||
await _unitOfWork.QuoteItems.UpdateAsync(item);
|
||||
|
||||
// Cascade delta through stored totals without re-running the pricing engine
|
||||
var delta = item.TotalPrice - oldTotal;
|
||||
quote.ItemsSubtotal += delta;
|
||||
quote.SubTotal += delta;
|
||||
quote.SubtotalAfterDiscount = quote.SubTotal - quote.DiscountAmount;
|
||||
quote.TaxAmount = Math.Round(quote.SubtotalAfterDiscount * quote.TaxPercent / 100m, 2);
|
||||
quote.Total = Math.Round(quote.SubtotalAfterDiscount + quote.RushFee + quote.TaxAmount, 2);
|
||||
await _unitOfWork.Quotes.UpdateAsync(quote);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return Json(new {
|
||||
lineTotal = item.TotalPrice,
|
||||
subtotal = quote.SubTotal,
|
||||
taxAmount = quote.TaxAmount,
|
||||
total = quote.Total
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Request model for AJAX pricing calculation
|
||||
@@ -3834,3 +3877,11 @@ public class UpdateQuoteStatusRequest
|
||||
public int QuoteId { get; set; }
|
||||
public int StatusId { get; set; }
|
||||
}
|
||||
|
||||
public class PatchQuoteItemRequest
|
||||
{
|
||||
public int ItemId { get; set; }
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public decimal Quantity { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
}
|
||||
|
||||
@@ -265,12 +265,15 @@ public static class HelpKnowledgeBase
|
||||
**Job Priorities (color-coded):**
|
||||
- Low (grey), Normal (blue), High (orange), Urgent (red), Rush (purple)
|
||||
|
||||
**Jobs list default view:** The Jobs list opens on the **On Floor** filter by default — showing only active jobs (excludes Completed, Ready for Pickup, Delivered, Cancelled). Use the filter pills at the top to switch views: **All** shows every job regardless of status; **On Floor** shows active work; **Overdue** shows past-due active jobs; **Ready** shows jobs awaiting customer pickup; **Completed** shows all finished jobs (Completed + Ready for Pickup + Delivered). Each pill shows a live global count.
|
||||
|
||||
**How to create a job:**
|
||||
1. Go to [Jobs](/Jobs) → "New Job"
|
||||
2. Select customer
|
||||
3. Add line items (same wizard as quotes: Calculated, Custom Work, or AI Photo)
|
||||
4. Set priority, due date, assigned worker, special instructions
|
||||
5. Save
|
||||
5. Optionally set Oven & Batch Settings — select a named oven, number of batches, and cycle time. These affect the oven cost in pricing.
|
||||
6. Save
|
||||
|
||||
**Job Priority Board:** [/JobsPriority](/JobsPriority) — Kanban-style view of all active jobs sorted by priority and status.
|
||||
|
||||
@@ -302,7 +305,9 @@ public static class HelpKnowledgeBase
|
||||
|
||||
**Changing the customer on a job:** On the Job Details page, the Customer field is an always-visible dropdown. Select a different customer — a confirmation banner appears. Click **Save** to apply or **Cancel** to revert. Use this to correct a misassigned job or to move a walk-in job to a customer's proper record after they've been added to the system.
|
||||
|
||||
**Creating an invoice from a job:** On the Job Details page, look for the Invoice section and click "Create Invoice." The system pre-fills all line items, pricing, discount, tax rate, payment terms, and due date from the job and customer automatically. Review the Totals panel on the right — if a discount was applied to the job it will show as a red "Discount Applied" line. Adjust anything you need, then save.
|
||||
**Completing a job:** When a job is ready to mark complete, click the **Complete Job** button on the Job Details page. A modal appears asking you to confirm the completion date, actual hours spent, and final price. If the job used powder from inventory, you will be asked to enter the actual lbs used — the modal groups all coats by unique powder color (not per coat or per item) so you fill in one quantity per powder. The system deducts the entered amounts from inventory, crediting any quantities already logged via QR scan. Once confirmed, the job advances to Completed status, and you are prompted to create the invoice if one does not exist.
|
||||
|
||||
**Creating an invoice from a job:** On the Job Details page, look for the Invoice section and click "Create Invoice." The system pre-fills all line items, pricing, discount, tax rate, payment terms, and due date from the job and customer automatically. Line item descriptions include the coat color(s) for each item (e.g., "Color1 / Color2" if multiple coats), helping customers distinguish repeated items on the invoice. Review the Totals panel on the right — if a discount was applied to the job it will show as a red "Discount Applied" line. Adjust anything you need, then save.
|
||||
|
||||
**Work Order QR Codes:** Every printed job work order includes two tiers of QR codes — one for viewing the job, and a separate set for taking action on it. All QR codes require the worker to be logged in.
|
||||
|
||||
|
||||
@@ -240,6 +240,7 @@ builder.Services.AddHostedService<AuditLogRetentionBackgroundService>();
|
||||
builder.Services.AddHostedService<StripeWebhookRetentionBackgroundService>();
|
||||
builder.Services.AddHostedService<SetupWizardReminderBackgroundService>();
|
||||
builder.Services.AddHostedService<RecurringTransactionService>();
|
||||
builder.Services.AddHostedService<AppointmentReminderBackgroundService>();
|
||||
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
||||
builder.Services.AddScoped<IStripeService, StripeService>();
|
||||
builder.Services.AddScoped<IStripeConnectService, StripeConnectService>();
|
||||
@@ -652,6 +653,11 @@ app.Use(async (context, next) =>
|
||||
context.Response.Headers.Append("Permissions-Policy",
|
||||
"geolocation=(), microphone=(), camera=()");
|
||||
|
||||
// Prevent browsers from caching authenticated pages — avoids stale data and
|
||||
// browser-specific cache corruption bugs (e.g. Firefox caching a partial load).
|
||||
if (context.User.Identity?.IsAuthenticated == true)
|
||||
context.Response.Headers.Append("Cache-Control", "no-store");
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">Not set — invoices go to contact email</span>
|
||||
<span class=”text-muted”>Not set — invoices go to contact email</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -52,12 +52,13 @@
|
||||
<div class="card-body p-0">
|
||||
@if (!Model.Items.Any())
|
||||
{
|
||||
var isCustomerListFiltered = !string.IsNullOrEmpty(ViewBag.SearchTerm as string);
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
|
||||
<h5 class="mt-3 text-muted">No customers found</h5>
|
||||
<p class="text-muted mb-4">Get started by adding your first customer</p>
|
||||
<p class="text-muted mb-4">@(isCustomerListFiltered ? "No customers match your search." : "Get started by adding your first customer.")</p>
|
||||
<a asp-action="Create" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-2"></i>Add Your First Customer
|
||||
<i class="bi bi-plus-circle me-2"></i>@(isCustomerListFiltered ? "Add Customer" : "Add Your First Customer")
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
@@ -184,12 +185,13 @@
|
||||
<div class="mobile-card-view">
|
||||
@if (!Model.Items.Any())
|
||||
{
|
||||
var isMobileCustomerListFiltered = !string.IsNullOrEmpty(ViewBag.SearchTerm as string);
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
|
||||
<h5 class="mt-3 text-muted">No customers found</h5>
|
||||
<p class="text-muted mb-4">Get started by adding your first customer</p>
|
||||
<p class="text-muted mb-4">@(isMobileCustomerListFiltered ? "No customers match your search." : "Get started by adding your first customer.")</p>
|
||||
<a asp-action="Create" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-2"></i>Add Your First Customer
|
||||
<i class="bi bi-plus-circle me-2"></i>@(isMobileCustomerListFiltered ? "Add Customer" : "Add Your First Customer")
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -61,12 +61,13 @@
|
||||
<div class="card-body p-0">
|
||||
@if (!Model.Items.Any())
|
||||
{
|
||||
var isEquipmentListFiltered = !string.IsNullOrEmpty(ViewBag.SearchTerm as string) || ViewBag.StatusFilter != null;
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
|
||||
<h5 class="mt-3 text-muted">No equipment found</h5>
|
||||
<p class="text-muted mb-4">Get started by adding your first equipment</p>
|
||||
<p class="text-muted mb-4">@(isEquipmentListFiltered ? "No equipment matches your search." : "Get started by adding your first equipment.")</p>
|
||||
<a asp-action="Create" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-2"></i>Add Your First Equipment
|
||||
<i class="bi bi-plus-circle me-2"></i>@(isEquipmentListFiltered ? "Add Equipment" : "Add Your First Equipment")
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -25,9 +25,13 @@
|
||||
a priority level, a due date, and one or more line items describing the work to be performed.
|
||||
</p>
|
||||
<p>
|
||||
You can find Jobs under <strong>Operations › Jobs</strong> in the left sidebar. The list is
|
||||
searchable and sortable by job number, status, priority, scheduled date, due date, and price. Jobs
|
||||
can be created manually or converted automatically from an approved quote — no need to re-enter
|
||||
You can find Jobs under <strong>Operations › Jobs</strong> in the left sidebar. The list
|
||||
opens on the <strong>On Floor</strong> filter by default, showing only active in-progress work
|
||||
so completed jobs don’t clutter the screen. Use the filter pills at the top to switch
|
||||
views: <strong>All</strong>, <strong>On Floor</strong>, <strong>Overdue</strong>,
|
||||
<strong>Ready</strong> (awaiting pickup), and <strong>Completed</strong>. The list is sortable
|
||||
and searchable by job number, status, priority, scheduled date, due date, and price. Jobs can be
|
||||
created manually or converted automatically from an approved quote — no need to re-enter
|
||||
information that is already in the system.
|
||||
</p>
|
||||
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
|
||||
@@ -54,6 +58,11 @@
|
||||
<li class="mb-2">Enter the customer's <strong>PO Number</strong> if they require one for their own records.</li>
|
||||
<li class="mb-2">Add any <strong>Special Instructions</strong> your team needs to know before starting work.</li>
|
||||
<li class="mb-2">Add one or more <strong>Line Items</strong> describing each piece being coated. See the Job Items section below.</li>
|
||||
<li class="mb-2">
|
||||
Optionally expand <strong>Oven & Batch Settings</strong> to assign a named oven, set the
|
||||
number of batches, and enter the cure cycle time. These values feed directly into the oven cost
|
||||
calculation so the job’s pricing reflects the actual oven run.
|
||||
</li>
|
||||
<li class="mb-2">Click <strong>Save Job</strong>.</li>
|
||||
</ol>
|
||||
<p>
|
||||
@@ -353,7 +362,7 @@
|
||||
<li>If an invoice already exists, you will see a link to open it.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="mb-2">Click <strong>Create Invoice</strong>. The system generates a new invoice pre-filled with all the job's line items and the final pricing.</li>
|
||||
<li class="mb-2">Click <strong>Create Invoice</strong>. The system generates a new invoice pre-filled with all the job’s line items and the final pricing. Line item descriptions include the coat color(s) for each item (e.g., <em>Gloss Black / Satin Clear</em>), which helps customers distinguish repeated items such as multiple sets of calipers.</li>
|
||||
<li class="mb-2">Review the invoice, confirm the due date, and save it.</li>
|
||||
</ol>
|
||||
<p>
|
||||
@@ -370,6 +379,40 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="completing-a-job" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-check-circle text-primary me-2"></i>Completing a Job
|
||||
</h2>
|
||||
<p>
|
||||
When work is done and the parts have passed quality check, click the <strong>Complete Job</strong>
|
||||
button on the Job Details page. A modal opens where you confirm:
|
||||
</p>
|
||||
<ul class="mb-3">
|
||||
<li><strong>Completion date</strong> — defaults to today.</li>
|
||||
<li><strong>Actual hours spent</strong> on the job.</li>
|
||||
<li><strong>Final price</strong> — pre-filled from the job; adjust if needed.</li>
|
||||
<li>
|
||||
<strong>Powder usage</strong> — if the job used powder from inventory, you are asked
|
||||
to enter actual lbs used. The modal groups all coats by <em>unique powder color</em> and
|
||||
shows one input row per powder, regardless of how many items or coats used that color.
|
||||
This avoids entering the same number repeatedly. Any lbs already scanned via QR code on
|
||||
the shop floor are credited automatically — you only enter the remaining amount.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Once confirmed, the job advances to <span class="badge bg-success">Completed</span> status,
|
||||
inventory is updated, and you are prompted to create an invoice if one does not already exist.
|
||||
</p>
|
||||
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
|
||||
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
Shop floor workers who complete a job via QR scan bypass the modal — the SMS
|
||||
notification is sent immediately using the configured template. Managers and admins
|
||||
get the full modal with a compose step before the SMS goes out.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="photos-notes" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-camera text-primary me-2"></i>Photos and Notes
|
||||
@@ -699,6 +742,7 @@
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#job-items">Job Items</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#converting-from-quote">Converting from a Quote</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#creating-an-invoice">Creating an Invoice</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#completing-a-job">Completing a Job</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#photos-notes">Photos and Notes</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#time-and-rework">Time Entries and Rework</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#job-templates">Job Templates</a>
|
||||
|
||||
@@ -191,12 +191,13 @@
|
||||
<div class="card-body p-0">
|
||||
@if (!Model.Items.Any())
|
||||
{
|
||||
var isInventoryFiltered = !string.IsNullOrEmpty(ViewBag.SearchTerm as string) || !string.IsNullOrEmpty(ViewBag.Category as string) || lowStockOnly;
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
|
||||
<h5 class="mt-3 text-muted">No inventory items found</h5>
|
||||
<p class="text-muted mb-4">Get started by adding your first inventory item</p>
|
||||
<p class="text-muted mb-4">@(isInventoryFiltered ? "No items match your current filters." : "Get started by adding your first inventory item.")</p>
|
||||
<a asp-action="Create" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-2"></i>Add Your First Item
|
||||
<i class="bi bi-plus-circle me-2"></i>@(isInventoryFiltered ? "Add Item" : "Add Your First Item")
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
var onlinePaymentsEnabled = ViewBag.OnlinePaymentsEnabled == true;
|
||||
var showOnlinePaymentCard = !isDraft && !isVoided && Model.BalanceDue > 0 && onlinePaymentsEnabled;
|
||||
var guidedActivationCallout = ViewBag.GuidedActivationCallout as PowderCoating.Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel;
|
||||
var nonDepositPaymentTotal = Model.Payments
|
||||
.Where(p => !(p.Reference ?? "").StartsWith("Deposit "))
|
||||
.Sum(p => p.Amount);
|
||||
}
|
||||
|
||||
<div class="row justify-content-center">
|
||||
@@ -230,21 +233,21 @@
|
||||
<tbody>
|
||||
@foreach (var item in Model.InvoiceItems)
|
||||
{
|
||||
<tr>
|
||||
<tr data-item-id="@item.Id">
|
||||
<td>
|
||||
<div class="fw-semibold">@item.Description</div>
|
||||
<span class="fw-semibold" data-inline-field="description" data-raw-value="@item.Description">@item.Description</span>
|
||||
@if (!string.IsNullOrWhiteSpace(item.ColorName))
|
||||
{
|
||||
<small class="text-muted">@item.ColorName</small>
|
||||
<small class="text-muted d-block">@item.ColorName</small>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(item.Notes))
|
||||
{
|
||||
<small class="text-muted d-block">@item.Notes</small>
|
||||
}
|
||||
</td>
|
||||
<td class="text-center">@item.Quantity.ToString("G")</td>
|
||||
<td class="text-end">@item.UnitPrice.ToString("C")</td>
|
||||
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
|
||||
<td class="text-center"><span data-inline-field="quantity" data-raw-value="@item.Quantity">@item.Quantity.ToString("G")</span></td>
|
||||
<td class="text-end"><span data-inline-field="unitPrice" data-raw-value="@item.UnitPrice">@item.UnitPrice.ToString("C")</span></td>
|
||||
<td class="text-end fw-semibold" data-line-total>@item.TotalPrice.ToString("C")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
@@ -257,7 +260,7 @@
|
||||
<div class="col-md-4">
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<span class="text-muted">Subtotal</span>
|
||||
<span>@Model.SubTotal.ToString("C")</span>
|
||||
<span id="inv-subtotal">@Model.SubTotal.ToString("C")</span>
|
||||
</div>
|
||||
@if (Model.DiscountAmount > 0)
|
||||
{
|
||||
@@ -276,12 +279,12 @@
|
||||
<span class="badge bg-warning-subtle text-warning-emphasis ms-1 small fw-normal">@Model.SalesTaxAccountName</span>
|
||||
}
|
||||
</span>
|
||||
<span>@Model.TaxAmount.ToString("C")</span>
|
||||
<span id="inv-tax">@Model.TaxAmount.ToString("C")</span>
|
||||
</div>
|
||||
}
|
||||
<div class="d-flex justify-content-between fw-bold border-top pt-2 mt-1 fs-5">
|
||||
<span>Total</span>
|
||||
<span>@Model.Total.ToString("C")</span>
|
||||
<span id="inv-total">@Model.Total.ToString("C")</span>
|
||||
</div>
|
||||
@if (Model.AmountPaid > 0)
|
||||
{
|
||||
@@ -291,7 +294,7 @@
|
||||
</div>
|
||||
<div class="d-flex justify-content-between fw-bold border-top pt-2 mt-1">
|
||||
<span>Balance Due</span>
|
||||
<span class="@(Model.BalanceDue > 0 ? "text-danger" : "text-success")">@Model.BalanceDue.ToString("C")</span>
|
||||
<span id="inv-balance" class="@(Model.BalanceDue > 0 ? "text-danger" : "text-success")">@Model.BalanceDue.ToString("C")</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -406,7 +409,7 @@
|
||||
@if (!isVoided)
|
||||
{
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary me-1" title="Edit payment"
|
||||
onclick="openEditPaymentModal(@p.Id, @Model.Id, '@p.PaymentDate.ToString("yyyy-MM-dd")', @((int)p.PaymentMethod), '@(p.Reference ?? "")', '@(p.Notes ?? "")', @(p.DepositAccountId?.ToString() ?? "null"))">
|
||||
onclick="openEditPaymentModal(@p.Id, @Model.Id, '@p.PaymentDate.ToString("yyyy-MM-dd")', @((int)p.PaymentMethod), @Json.Serialize(p.Reference ?? ""), @Json.Serialize(p.Notes ?? ""), @(p.DepositAccountId?.ToString() ?? "null"))">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<form asp-action="DeletePayment" asp-route-invoiceId="@Model.Id" asp-route-paymentId="@p.Id"
|
||||
@@ -694,7 +697,9 @@
|
||||
@if (!isVoided && Model.Status != InvoiceStatus.Paid)
|
||||
{
|
||||
<form asp-action="Void" asp-route-id="@Model.Id" method="post"
|
||||
onsubmit="return confirm('Void this invoice? This will reverse the remaining balance on the customer account.')">
|
||||
onsubmit="return confirm('@(nonDepositPaymentTotal > 0
|
||||
? $"Void this invoice? {nonDepositPaymentTotal.ToString("C")} in payments will be converted to a customer credit and auto-applied to the next invoice."
|
||||
: "Void this invoice? This will reverse the remaining balance on the customer account.")')">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-outline-warning w-100">
|
||||
<i class="bi bi-x-circle me-2"></i>Void Invoice
|
||||
@@ -869,42 +874,45 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (showSendModal)
|
||||
{
|
||||
<!-- Send Channel Choice Modal (shown when customer has both email + SMS) -->
|
||||
<div class="modal fade" id="sendChannelModal" tabindex="-1" aria-labelledby="sendChannelModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header border-0 pb-0">
|
||||
<h5 class="modal-title" id="sendChannelModalLabel">
|
||||
<i class="bi bi-send text-primary me-2"></i>Send Invoice
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body pt-2">
|
||||
<p class="mb-3">How would you like to send <strong>@Model.InvoiceNumber</strong> to <strong>@Model.CustomerName</strong>?</p>
|
||||
<div class="d-grid gap-2">
|
||||
<button type="button" class="btn btn-outline-primary text-start" onclick="submitSendInvoice(true, false)" data-bs-dismiss="modal">
|
||||
<i class="bi bi-envelope me-2"></i>Email only
|
||||
<small class="d-block text-muted ms-4">PDF attached · @Model.CustomerEmail</small>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-primary text-start" onclick="submitSendInvoice(false, true)" data-bs-dismiss="modal">
|
||||
<i class="bi bi-phone me-2"></i>SMS only
|
||||
<small class="d-block text-muted ms-4">View link · @smsPhone</small>
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary text-start" onclick="submitSendInvoice(true, true)" data-bs-dismiss="modal">
|
||||
<i class="bi bi-send me-2"></i>Both Email & SMS
|
||||
<small class="d-block text-muted ms-4">PDF via email + view link via SMS</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-0 pt-0">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
}
|
||||
|
||||
@if (showSendModal)
|
||||
{
|
||||
<!-- Send Channel Choice Modal (shown when customer has both email + SMS available).
|
||||
Lives outside the isDraft block so it also renders for Sent/Overdue invoices
|
||||
where the customer's email was added after an SMS-only initial send. -->
|
||||
<div class="modal fade" id="sendChannelModal" tabindex="-1" aria-labelledby="sendChannelModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header border-0 pb-0">
|
||||
<h5 class="modal-title" id="sendChannelModalLabel">
|
||||
<i class="bi bi-send text-primary me-2"></i>Send Invoice
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body pt-2">
|
||||
<p class="mb-3">How would you like to send <strong>@Model.InvoiceNumber</strong> to <strong>@Model.CustomerName</strong>?</p>
|
||||
<div class="d-grid gap-2">
|
||||
<button type="button" class="btn btn-outline-primary text-start" onclick="submitSendInvoice(true, false)" data-bs-dismiss="modal">
|
||||
<i class="bi bi-envelope me-2"></i>Email only
|
||||
<small class="d-block text-muted ms-4">PDF attached · @Model.CustomerEmail</small>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-primary text-start" onclick="submitSendInvoice(false, true)" data-bs-dismiss="modal">
|
||||
<i class="bi bi-phone me-2"></i>SMS only
|
||||
<small class="d-block text-muted ms-4">View link · @smsPhone</small>
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary text-start" onclick="submitSendInvoice(true, true)" data-bs-dismiss="modal">
|
||||
<i class="bi bi-send me-2"></i>Both Email & SMS
|
||||
<small class="d-block text-muted ms-4">PDF via email + view link via SMS</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-0 pt-0">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (canPay)
|
||||
@@ -1439,6 +1447,19 @@
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/inline-item-edit.js"></script>
|
||||
<script>
|
||||
window.inlineItemEdit = {
|
||||
patchUrl: '@Url.Action("PatchItem", "Invoices")',
|
||||
canEdit: @Json.Serialize(canEdit),
|
||||
totals: {
|
||||
subtotal: '#inv-subtotal',
|
||||
tax: '#inv-tax',
|
||||
total: '#inv-total',
|
||||
balance: '#inv-balance'
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<script>
|
||||
function submitSendInvoice(sendEmail, sendSms) {
|
||||
document.getElementById('sendInvoiceSendEmail').value = sendEmail ? 'true' : 'false';
|
||||
|
||||
@@ -221,6 +221,49 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Oven & Batch Settings -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<h5 class="mb-0"><i class="bi bi-thermometer-half me-2"></i>Oven & Batch Settings</h5>
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Oven & Batch Pricing"
|
||||
data-bs-content="The oven cost is charged once per batch at the job level, not per item. Estimate how many oven loads the full job will fill &mdash; for example, if you have 20 small parts and your oven fits 10, that&#39;s 2 batches. Cycle time is how long each batch runs. The cost is calculated from your oven&#39;s hourly rate in Settings.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-3">Estimate how many oven loads the complete job will require. The oven cycle cost is added once at the job level, not per item.</p>
|
||||
<div class="row g-3 align-items-end">
|
||||
@if (ViewBag.OvenCosts != null && ((List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.OvenCosts).Count > 1)
|
||||
{
|
||||
<div class="col-md-4">
|
||||
<label asp-for="OvenCostId" class="form-label fw-semibold"><i class="bi bi-thermometer-half me-1"></i>Oven</label>
|
||||
<select asp-for="OvenCostId" asp-items="@((List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.OvenCosts)"
|
||||
class="form-select" onchange="scheduleAutoPricing()"></select>
|
||||
</div>
|
||||
}
|
||||
<div class="col-sm-3 col-md-2">
|
||||
<label asp-for="OvenBatches" class="form-label fw-semibold">Batches</label>
|
||||
<input asp-for="OvenBatches" class="form-control" type="number" min="1"
|
||||
value="@(Model.OvenBatches > 0 ? Model.OvenBatches : 1)"
|
||||
id="OvenBatches" onchange="scheduleAutoPricing()" />
|
||||
</div>
|
||||
<div class="col-sm-4 col-md-3">
|
||||
<label asp-for="OvenCycleMinutes" class="form-label fw-semibold">
|
||||
Cycle Time per Batch (min)
|
||||
<small class="text-muted fw-normal">default: @(ViewBag.DefaultOvenCycleMinutes ?? 45)</small>
|
||||
</label>
|
||||
<input asp-for="OvenCycleMinutes" class="form-control" type="number" min="1"
|
||||
id="OvenCycleMinutes" placeholder="@(ViewBag.DefaultOvenCycleMinutes ?? 45)"
|
||||
onchange="scheduleAutoPricing()" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pricing Options (Rush / Discount) -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
@@ -370,7 +413,8 @@
|
||||
transferEfficiency = c.TransferEfficiency,
|
||||
powderCostPerLb = c.PowderCostPerLb,
|
||||
powderToOrder = c.PowderToOrder,
|
||||
notes = c.Notes
|
||||
notes = c.Notes,
|
||||
noExtraLayerCharge = c.NoExtraLayerCharge
|
||||
}),
|
||||
prepServices = item.PrepServices.Select(ps => new {
|
||||
prepServiceId = ps.PrepServiceId,
|
||||
@@ -387,7 +431,7 @@
|
||||
"discountType": @Json.Serialize(Model.DiscountType),
|
||||
"discountValue": @Model.DiscountValue,
|
||||
"isRushJob": @Json.Serialize(Model.IsRushJob),
|
||||
"ovenCostId": null,
|
||||
"ovenCostId": @Json.Serialize(Model.OvenCostId),
|
||||
"areaUnit": @Json.Serialize((string?)ViewBag.AreaUnit),
|
||||
"useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)),
|
||||
"pricingUrl": "@Url.Action("CalculatePricing", "Jobs")",
|
||||
|
||||
@@ -324,6 +324,7 @@
|
||||
@* -- Catalog Products -- *@
|
||||
@if (catalogItems.Any())
|
||||
{
|
||||
<div class="d-none d-lg-block">
|
||||
<h6 class="text-primary mb-3"><i class="bi bi-bag-check me-2"></i>Catalog Products</h6>
|
||||
<div class="table-responsive mb-4">
|
||||
<table class="table table-hover table-sm">
|
||||
@@ -340,9 +341,9 @@
|
||||
@foreach (var item in catalogItems)
|
||||
{
|
||||
var catIdx = allItems.IndexOf(item);
|
||||
<tr>
|
||||
<tr data-item-id="@item.Id">
|
||||
<td>
|
||||
<strong>@item.Description</strong>
|
||||
<span data-inline-field="description" data-raw-value="@item.Description"><strong>@item.Description</strong></span>
|
||||
@if (item.Coats != null && item.Coats.Any())
|
||||
{
|
||||
<br />
|
||||
@@ -398,9 +399,9 @@
|
||||
<br /><small class="text-muted"><i class="bi bi-sticky me-1"></i>@item.Notes</small>
|
||||
}
|
||||
</td>
|
||||
<td class="text-center">@item.Quantity</td>
|
||||
<td class="text-end">@item.UnitPrice.ToString("C")</td>
|
||||
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
|
||||
<td class="text-center"><span data-inline-field="quantity" data-raw-value="@item.Quantity">@item.Quantity</span></td>
|
||||
<td class="text-end"><span data-inline-field="unitPrice" data-raw-value="@item.UnitPrice">@item.UnitPrice.ToString("C")</span></td>
|
||||
<td class="text-end fw-semibold" data-line-total>@item.TotalPrice.ToString("C")</td>
|
||||
<td class="text-center">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="openWizard(@catIdx)" title="Edit"><i class="bi bi-pencil"></i></button>
|
||||
@@ -412,11 +413,13 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* -- Custom Work -- *@
|
||||
@if (customItems.Any())
|
||||
{
|
||||
<div class="d-none d-lg-block">
|
||||
<h6 class="text-success mb-3"><i class="bi bi-calculator me-2"></i>Custom Work</h6>
|
||||
<div class="table-responsive mb-4">
|
||||
<table class="table table-hover table-sm">
|
||||
@@ -437,6 +440,7 @@
|
||||
{
|
||||
var custIdx = allItems.IndexOf(item);
|
||||
// Use stored PowderToOrder per coat; fall back to calculating from efficiency data
|
||||
// Note: row has data-item-id for inline editing
|
||||
decimal totalPowderNeeded = 0;
|
||||
if (item.Coats != null && item.Coats.Any(c => c.PowderToOrder > 0))
|
||||
{
|
||||
@@ -455,9 +459,9 @@
|
||||
{
|
||||
totalPowderNeeded = (item.SurfaceAreaSqFt * item.Quantity) / (30m * 0.65m);
|
||||
}
|
||||
<tr>
|
||||
<tr data-item-id="@item.Id">
|
||||
<td>
|
||||
<strong>@item.Description</strong>
|
||||
<span data-inline-field="description" data-raw-value="@item.Description"><strong>@item.Description</strong></span>
|
||||
@if (item.RequiresSandblasting || item.RequiresMasking)
|
||||
{
|
||||
<br />
|
||||
@@ -525,7 +529,7 @@
|
||||
<br /><small class="text-muted"><i class="bi bi-sticky me-1"></i>@item.Notes</small>
|
||||
}
|
||||
</td>
|
||||
<td class="text-center">@item.Quantity</td>
|
||||
<td class="text-center"><span data-inline-field="quantity" data-raw-value="@item.Quantity">@item.Quantity</span></td>
|
||||
<td class="text-center">
|
||||
@if (item.SurfaceAreaSqFt > 0)
|
||||
{
|
||||
@@ -550,8 +554,8 @@
|
||||
}
|
||||
else { <span class="text-muted">—</span> }
|
||||
</td>
|
||||
<td class="text-end">@item.UnitPrice.ToString("C")</td>
|
||||
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
|
||||
<td class="text-end"><span data-inline-field="unitPrice" data-raw-value="@item.UnitPrice">@item.UnitPrice.ToString("C")</span></td>
|
||||
<td class="text-end fw-semibold" data-line-total>@item.TotalPrice.ToString("C")</td>
|
||||
<td class="text-center">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="openWizard(@custIdx)" title="Edit"><i class="bi bi-pencil"></i></button>
|
||||
@@ -563,11 +567,13 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* -- Labor -- *@
|
||||
@if (laborItems.Any())
|
||||
{
|
||||
<div class="d-none d-lg-block">
|
||||
<h6 class="text-warning mb-3"><i class="bi bi-person-gear me-2"></i>Labor</h6>
|
||||
<div class="table-responsive mb-4">
|
||||
<table class="table table-hover table-sm">
|
||||
@@ -585,15 +591,15 @@
|
||||
@foreach (var item in laborItems)
|
||||
{
|
||||
var labIdx = allItems.IndexOf(item);
|
||||
<tr>
|
||||
<tr data-item-id="@item.Id">
|
||||
<td>
|
||||
<strong>@item.Description</strong>
|
||||
<span data-inline-field="description" data-raw-value="@item.Description"><strong>@item.Description</strong></span>
|
||||
@if (!string.IsNullOrEmpty(item.Notes))
|
||||
{
|
||||
<br /><small class="text-muted"><i class="bi bi-sticky me-1"></i>@item.Notes</small>
|
||||
}
|
||||
</td>
|
||||
<td class="text-center">@item.Quantity</td>
|
||||
<td class="text-center"><span data-inline-field="quantity" data-raw-value="@item.Quantity">@item.Quantity</span></td>
|
||||
<td class="text-center">
|
||||
@if (item.EstimatedMinutes > 0)
|
||||
{
|
||||
@@ -601,8 +607,8 @@
|
||||
}
|
||||
else { <span class="text-muted">—</span> }
|
||||
</td>
|
||||
<td class="text-end">@item.UnitPrice.ToString("C")</td>
|
||||
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
|
||||
<td class="text-end"><span data-inline-field="unitPrice" data-raw-value="@item.UnitPrice">@item.UnitPrice.ToString("C")</span></td>
|
||||
<td class="text-end fw-semibold" data-line-total>@item.TotalPrice.ToString("C")</td>
|
||||
<td class="text-center">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="openWizard(@labIdx)" title="Edit"><i class="bi bi-pencil"></i></button>
|
||||
@@ -614,6 +620,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* -- Mobile cards -- *@
|
||||
@@ -653,7 +660,10 @@
|
||||
<span class="mobile-card-value">
|
||||
@foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
|
||||
{
|
||||
<small class="d-block">@coat.CoatName@if (!string.IsNullOrEmpty(coat.ColorName)) { <text> – @coat.ColorName</text> }</small>
|
||||
<small class="d-block">
|
||||
@coat.CoatName@if (!string.IsNullOrEmpty(coat.ColorName)) { <text> – @coat.ColorName</text> }
|
||||
@if (!string.IsNullOrEmpty(coat.Notes)) { <text><br /><span class="fst-italic text-muted ms-2">@coat.Notes</span></text> }
|
||||
</small>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1679,7 +1689,7 @@
|
||||
}
|
||||
<div class="d-flex justify-content-between fw-bold border-top pt-2 mt-1">
|
||||
<span>Total</span>
|
||||
<span>@jobPb.Total.ToString("C")</span>
|
||||
<span class="job-final-price-display">@jobPb.Total.ToString("C")</span>
|
||||
</div>
|
||||
@{
|
||||
var jobTotalDirectCost = jobPb.MaterialCosts + jobPb.LaborCosts + jobPb.EquipmentCosts + jobPb.OvenBatchCost + jobPb.FacilityOverheadCost + jobPb.ShopSuppliesAmount;
|
||||
@@ -1708,7 +1718,7 @@
|
||||
}
|
||||
<div>
|
||||
<label class="text-muted small mb-1">Final Price</label>
|
||||
<h3 class="mb-0 text-primary">@Model.FinalPrice.ToString("C")</h3>
|
||||
<h3 class="mb-0 text-primary job-final-price-display">@Model.FinalPrice.ToString("C")</h3>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -2406,6 +2416,16 @@
|
||||
<link rel="stylesheet" href="~/css/job-photos.css" />
|
||||
<script src="~/js/job-photos.js" asp-append-version="true"></script>
|
||||
<script src="~/js/customer-change.js" asp-append-version="true"></script>
|
||||
<script src="~/js/inline-item-edit.js" asp-append-version="true"></script>
|
||||
<script>
|
||||
window.inlineItemEdit = {
|
||||
patchUrl: '@Url.Action("PatchItem", "Jobs")',
|
||||
canEdit: true,
|
||||
totals: {
|
||||
finalPrice: '.job-final-price-display'
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<script>
|
||||
// -- Inline date editing ----------------------------------------------
|
||||
const jobId = @Model.Id;
|
||||
|
||||
@@ -190,6 +190,49 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Oven & Batch Settings -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<h5 class="mb-0"><i class="bi bi-thermometer-half me-2"></i>Oven & Batch Settings</h5>
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Oven & Batch Pricing"
|
||||
data-bs-content="The oven cost is charged once per batch at the job level, not per item. Estimate how many oven loads the full job will fill &mdash; for example, if you have 20 small parts and your oven fits 10, that&#39;s 2 batches. Cycle time is how long each batch runs. The cost is calculated from your oven&#39;s hourly rate in Settings.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-3">Estimate how many oven loads the complete job will require. The oven cycle cost is added once at the job level, not per item.</p>
|
||||
<div class="row g-3 align-items-end">
|
||||
@if (ViewBag.OvenCosts != null && ((List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.OvenCosts).Count > 1)
|
||||
{
|
||||
<div class="col-md-4">
|
||||
<label asp-for="OvenCostId" class="form-label fw-semibold"><i class="bi bi-thermometer-half me-1"></i>Oven</label>
|
||||
<select asp-for="OvenCostId" asp-items="@((List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.OvenCosts)"
|
||||
class="form-select" onchange="scheduleAutoPricing()"></select>
|
||||
</div>
|
||||
}
|
||||
<div class="col-sm-3 col-md-2">
|
||||
<label asp-for="OvenBatches" class="form-label fw-semibold">Batches</label>
|
||||
<input asp-for="OvenBatches" class="form-control" type="number" min="1"
|
||||
value="@(Model.OvenBatches > 0 ? Model.OvenBatches : 1)"
|
||||
id="OvenBatches" onchange="scheduleAutoPricing()" />
|
||||
</div>
|
||||
<div class="col-sm-4 col-md-3">
|
||||
<label asp-for="OvenCycleMinutes" class="form-label fw-semibold">
|
||||
Cycle Time per Batch (min)
|
||||
<small class="text-muted fw-normal">default: @(ViewBag.DefaultOvenCycleMinutes ?? 45)</small>
|
||||
</label>
|
||||
<input asp-for="OvenCycleMinutes" class="form-control" type="number" min="1"
|
||||
id="OvenCycleMinutes" placeholder="@(ViewBag.DefaultOvenCycleMinutes ?? 45)"
|
||||
onchange="scheduleAutoPricing()" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pricing Options (Rush / Discount) -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
@@ -357,7 +400,8 @@
|
||||
transferEfficiency = c.TransferEfficiency,
|
||||
powderCostPerLb = c.PowderCostPerLb,
|
||||
powderToOrder = c.PowderToOrder,
|
||||
notes = c.Notes
|
||||
notes = c.Notes,
|
||||
noExtraLayerCharge = c.NoExtraLayerCharge
|
||||
}),
|
||||
prepServices = item.PrepServices.Select(ps => new {
|
||||
prepServiceId = ps.PrepServiceId,
|
||||
@@ -374,7 +418,7 @@
|
||||
"discountType": @Json.Serialize(Model.DiscountType),
|
||||
"discountValue": @Model.DiscountValue,
|
||||
"isRushJob": @Json.Serialize(Model.IsRushJob),
|
||||
"ovenCostId": null,
|
||||
"ovenCostId": @Json.Serialize(Model.OvenCostId),
|
||||
"areaUnit": @Json.Serialize((string?)ViewBag.AreaUnit),
|
||||
"useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)),
|
||||
"pricingUrl": "@Url.Action("CalculatePricing", "Jobs")",
|
||||
|
||||
@@ -153,7 +153,8 @@
|
||||
transferEfficiency = c.TransferEfficiency,
|
||||
powderCostPerLb = c.PowderCostPerLb,
|
||||
powderToOrder = c.PowderToOrder,
|
||||
notes = c.Notes
|
||||
notes = c.Notes,
|
||||
noExtraLayerCharge = c.NoExtraLayerCharge
|
||||
}),
|
||||
prepServices = item.PrepServices.Select(ps => new {
|
||||
prepServiceId = ps.PrepServiceId,
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
}
|
||||
|
||||
@{
|
||||
var _wip = Model.Items.Count(j => j.StatusIsWIP);
|
||||
var _done = Model.Items.Count(j => j.StatusCode == "COMPLETED" || j.StatusCode == "READYFORPICKUP" || j.StatusCode == "DELIVERED");
|
||||
var _overdue = Model.Items.Count(j => j.DueDate.HasValue && j.DueDate.Value < DateTime.Now && j.StatusCode != "COMPLETED" && j.StatusCode != "READYFORPICKUP" && j.StatusCode != "DELIVERED" && j.StatusCode != "CANCELLED");
|
||||
var _value = Model.Items.Sum(j => j.FinalPrice);
|
||||
var _allCount = (int)(ViewBag.AllJobCount ?? 0);
|
||||
var _wip = (int)(ViewBag.ActiveCount ?? 0);
|
||||
var _done = (int)(ViewBag.CompletedCount ?? 0);
|
||||
var _ready = (int)(ViewBag.ReadyCount ?? 0);
|
||||
var _overdue = (int)(ViewBag.OverdueCount ?? 0);
|
||||
var _value = Model.Items.Sum(j => j.FinalPrice);
|
||||
}
|
||||
<div class="pcl-metric-strip">
|
||||
<div class="pcl-metric-strip-cell">
|
||||
@@ -39,23 +41,23 @@
|
||||
Showing <strong>@Model.TotalCount</strong> job(s) matching "<strong>@ViewBag.SearchTerm</strong>"
|
||||
<small class="text-muted ms-2">(searches job number, description, customer, PO, instructions, status, priority)</small>
|
||||
</div>
|
||||
<a href="@Url.Action("Index")" class="btn btn-sm btn-outline-secondary">
|
||||
<a href="@Url.Action("Index", new { statusGroup = "active" })" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-x me-1"></i>Clear Filter
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(ViewBag.StatusGroup as string))
|
||||
@if (!string.IsNullOrEmpty(ViewBag.StatusGroup as string) && ViewBag.StatusGroup != "active" && ViewBag.StatusGroup != "all")
|
||||
{
|
||||
var groupLabel = ViewBag.StatusGroup == "active" ? "Active Jobs (excluding completed & cancelled)"
|
||||
: ViewBag.StatusGroup == "overdue" ? "Overdue Jobs (past due date)"
|
||||
: ViewBag.StatusGroup;
|
||||
var groupLabel = ViewBag.StatusGroup == "overdue" ? "Overdue Jobs (past due date)"
|
||||
: ViewBag.StatusGroup == "completed" ? "Completed Jobs (completed, ready for pickup & delivered)"
|
||||
: (string)ViewBag.StatusGroup;
|
||||
<div class="alert alert-warning alert-permanent d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i class="bi bi-funnel-fill me-2"></i>
|
||||
Showing: <strong>@groupLabel</strong> — @Model.TotalCount result@(Model.TotalCount == 1 ? "" : "s")
|
||||
</div>
|
||||
<a href="@Url.Action("Index")" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-x me-1"></i>Show All
|
||||
<a href="@Url.Action("Index", new { statusGroup = "active" })" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-x me-1"></i>Back to On Floor
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
@@ -63,7 +65,8 @@
|
||||
@{
|
||||
var _activeGroup = ViewBag.StatusGroup as string;
|
||||
var _activeSearch = ViewBag.SearchTerm as string;
|
||||
var _noFilter = string.IsNullOrEmpty(_activeGroup) && string.IsNullOrEmpty(_activeSearch) && string.IsNullOrEmpty(ViewBag.TagFilter as string);
|
||||
// "all" is the explicit show-everything group (bare URL now redirects to "active")
|
||||
var _noFilter = _activeGroup == "all" && string.IsNullOrEmpty(_activeSearch) && string.IsNullOrEmpty(ViewBag.TagFilter as string);
|
||||
}
|
||||
<!-- Jobs Table Card -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
@@ -135,8 +138,8 @@
|
||||
</div>
|
||||
<!-- Row 2: quick-view pills -->
|
||||
<div class="pcl-pill-group">
|
||||
<a href="@Url.Action("Index")" class="pcl-pill @(_noFilter ? "active" : "")">
|
||||
All <span class="pcl-pill-count">@Model.TotalCount</span>
|
||||
<a href="@Url.Action("Index", new { statusGroup = "all" })" class="pcl-pill @(_noFilter ? "active" : "")">
|
||||
All <span class="pcl-pill-count">@_allCount</span>
|
||||
</a>
|
||||
<a href="@Url.Action("Index", new { statusGroup = "active" })" class="pcl-pill @(_activeGroup == "active" ? "active" : "")">
|
||||
On floor <span class="pcl-pill-count">@_wip</span>
|
||||
@@ -145,7 +148,10 @@
|
||||
Overdue <span class="pcl-pill-count">@_overdue</span>
|
||||
</a>
|
||||
<a href="@Url.Action("Index", new { searchTerm = "ReadyForPickup" })" class="pcl-pill @(_activeSearch == "ReadyForPickup" ? "active" : "")">
|
||||
Ready <span class="pcl-pill-count">@_done</span>
|
||||
Ready <span class="pcl-pill-count">@_ready</span>
|
||||
</a>
|
||||
<a href="@Url.Action("Index", new { statusGroup = "completed" })" class="pcl-pill @(_activeGroup == "completed" ? "active" : "")">
|
||||
Completed <span class="pcl-pill-count">@_done</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -75,11 +75,11 @@
|
||||
<div class="input-group mb-1">
|
||||
<span class="input-group-text">$</span>
|
||||
<input type="number" id="paymentAmount" class="form-control"
|
||||
value="@Model.BalanceDue.ToString("F2")"
|
||||
min="0.01" max="@Model.BalanceDue.ToString("F2")" step="0.01"
|
||||
value="@Model.TotalWithSurcharge.ToString("F2")"
|
||||
min="0.01" max="@Model.TotalWithSurcharge.ToString("F2")" step="0.01"
|
||||
oninput="updateSurcharge()" />
|
||||
</div>
|
||||
<div class="form-text">Max: @Model.BalanceDue.ToString("C")</div>
|
||||
<div class="form-text">Max: @Model.TotalWithSurcharge.ToString("C")</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -116,18 +116,17 @@
|
||||
const STRIPE_PK = '@Model.StripePublishableKey';
|
||||
const ACCOUNT_ID = '@Model.StripeAccountId';
|
||||
const TOKEN = '@Model.Token';
|
||||
const MAX_AMOUNT = @Model.BalanceDue.ToString("F2");
|
||||
const MAX_TOTAL = @Model.TotalWithSurcharge.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
|
||||
const SURCHARGE_TYPE = '@Model.SurchargeType';
|
||||
const SURCHARGE_VALUE = @Model.SurchargeValue.ToString("F4");
|
||||
const SURCHARGE_VALUE = @Model.SurchargeValue.ToString("F4", System.Globalization.CultureInfo.InvariantCulture);
|
||||
const SUCCESS_URL = `/pay/${TOKEN}/success`;
|
||||
|
||||
const stripe = Stripe(STRIPE_PK, { stripeAccount: ACCOUNT_ID });
|
||||
let elements, paymentElement;
|
||||
|
||||
async function initStripe() {
|
||||
const amount = parseFloat(document.getElementById('paymentAmount').value) || MAX_AMOUNT;
|
||||
const surcharge = calcSurcharge(amount);
|
||||
const total = Math.round((amount + surcharge) * 100) / 100;
|
||||
// Input value is the total the customer pays (including fee)
|
||||
const total = parseFloat(document.getElementById('paymentAmount').value) || MAX_TOTAL;
|
||||
|
||||
elements = stripe.elements({
|
||||
mode: 'payment',
|
||||
@@ -141,12 +140,15 @@
|
||||
paymentElement.mount('#payment-element');
|
||||
}
|
||||
|
||||
function calcSurcharge(amount) {
|
||||
// Back-calculates the base invoice amount from a total that includes the fee.
|
||||
// Server receives the base and adds surcharge on top — both paths arrive at the same
|
||||
// PaymentIntent amount so Stripe.js never sees an amount mismatch.
|
||||
function calcBaseFromTotal(total) {
|
||||
if (SURCHARGE_TYPE === 'Percent')
|
||||
return Math.round(amount * (SURCHARGE_VALUE / 100) * 100) / 100;
|
||||
return Math.round(total / (1 + SURCHARGE_VALUE / 100) * 100) / 100;
|
||||
if (SURCHARGE_TYPE === 'Flat')
|
||||
return SURCHARGE_VALUE;
|
||||
return 0;
|
||||
return Math.max(0, Math.round((total - SURCHARGE_VALUE) * 100) / 100);
|
||||
return total;
|
||||
}
|
||||
|
||||
function formatCurrency(val) {
|
||||
@@ -154,9 +156,9 @@
|
||||
}
|
||||
|
||||
function updateSurcharge() {
|
||||
const amount = Math.min(parseFloat(document.getElementById('paymentAmount').value) || 0, MAX_AMOUNT);
|
||||
const surcharge = calcSurcharge(amount);
|
||||
const total = amount + surcharge;
|
||||
const total = Math.min(parseFloat(document.getElementById('paymentAmount').value) || 0, MAX_TOTAL);
|
||||
const base = calcBaseFromTotal(total);
|
||||
const surcharge = Math.round((total - base) * 100) / 100;
|
||||
|
||||
const surchargeEl = document.getElementById('surchargeDisplay');
|
||||
const totalEl = document.getElementById('totalWithSurchargeDisplay');
|
||||
@@ -164,7 +166,10 @@
|
||||
|
||||
if (surchargeEl) surchargeEl.textContent = formatCurrency(surcharge);
|
||||
if (totalEl) totalEl.textContent = formatCurrency(total);
|
||||
if (btnAmtEl) btnAmtEl.textContent = surcharge > 0 ? formatCurrency(total) : formatCurrency(amount);
|
||||
if (btnAmtEl) btnAmtEl.textContent = formatCurrency(total);
|
||||
|
||||
// Keep Elements in sync so confirmPayment amount matches the PaymentIntent
|
||||
if (elements) elements.update({ amount: Math.round(total * 100) });
|
||||
}
|
||||
|
||||
async function submitPayment() {
|
||||
@@ -172,13 +177,21 @@
|
||||
const msgEl = document.getElementById('payment-message');
|
||||
const amountInput = document.getElementById('paymentAmount');
|
||||
|
||||
const amount = parseFloat(amountInput.value);
|
||||
if (!amount || amount <= 0 || amount > MAX_AMOUNT) {
|
||||
const total = parseFloat(amountInput.value);
|
||||
if (!total || total <= 0 || total > MAX_TOTAL) {
|
||||
msgEl.textContent = 'Please enter a valid payment amount.';
|
||||
msgEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Back-calculate base; server adds surcharge on top as usual
|
||||
const base = calcBaseFromTotal(total);
|
||||
if (base <= 0) {
|
||||
msgEl.textContent = 'Payment amount must exceed the convenience fee.';
|
||||
msgEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
document.getElementById('submitText').style.display = 'none';
|
||||
document.getElementById('submitSpinner').style.display = '';
|
||||
@@ -195,13 +208,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Create PaymentIntent on server
|
||||
// Create PaymentIntent on server — send base amount (server adds surcharge)
|
||||
let clientSecret;
|
||||
try {
|
||||
const resp = await fetch(`/pay/${TOKEN}/intent`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ amount })
|
||||
body: JSON.stringify({ amount: base })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) {
|
||||
|
||||
@@ -479,7 +479,8 @@
|
||||
transferEfficiency = c.TransferEfficiency,
|
||||
powderCostPerLb = c.PowderCostPerLb,
|
||||
powderToOrder = c.PowderToOrder,
|
||||
notes = c.Notes
|
||||
notes = c.Notes,
|
||||
noExtraLayerCharge = c.NoExtraLayerCharge
|
||||
})
|
||||
})))
|
||||
</script>
|
||||
|
||||
@@ -276,14 +276,14 @@
|
||||
<tbody>
|
||||
@foreach (var item in catalogItems)
|
||||
{
|
||||
<tr>
|
||||
<tr data-item-id="@item.Id">
|
||||
<td>
|
||||
@{
|
||||
var displayDescription = item.Description == "Product Item" || string.IsNullOrWhiteSpace(item.Description)
|
||||
? (item.CatalogItemName ?? "Catalog Item")
|
||||
: item.Description;
|
||||
}
|
||||
<strong>@displayDescription</strong>
|
||||
<span data-inline-field="description" data-raw-value="@displayDescription"><strong>@displayDescription</strong></span>
|
||||
@if (item.CatalogItemId.HasValue &&
|
||||
item.Description != "Product Item" &&
|
||||
!string.IsNullOrWhiteSpace(item.Description))
|
||||
@@ -354,9 +354,9 @@
|
||||
<small class="text-muted"><i class="bi bi-sticky"></i> <strong>Notes:</strong> @item.Notes</small>
|
||||
}
|
||||
</td>
|
||||
<td>@item.Quantity</td>
|
||||
<td>@item.UnitPrice.ToString("C")</td>
|
||||
<td><strong>@item.TotalPrice.ToString("C")</strong></td>
|
||||
<td><span data-inline-field="quantity" data-raw-value="@item.Quantity">@item.Quantity</span></td>
|
||||
<td><span data-inline-field="unitPrice" data-raw-value="@item.UnitPrice">@item.UnitPrice.ToString("C")</span></td>
|
||||
<td data-line-total><strong>@item.TotalPrice.ToString("C")</strong></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
@@ -394,9 +394,9 @@
|
||||
var totalSqFt = item.SurfaceAreaSqFt * item.Quantity;
|
||||
var powderOnPart = totalSqFt / coverageRate;
|
||||
var totalPowderNeeded = powderOnPart / transferEff;
|
||||
<tr>
|
||||
<tr data-item-id="@item.Id">
|
||||
<td>
|
||||
<strong>@item.Description</strong>
|
||||
<span data-inline-field="description" data-raw-value="@item.Description"><strong>@item.Description</strong></span>
|
||||
|
||||
@* Display coating layers *@
|
||||
@if (item.Coats != null && item.Coats.Any())
|
||||
@@ -474,7 +474,7 @@
|
||||
<small class="text-muted"><i class="bi bi-sticky"></i> <strong>Notes:</strong> @item.Notes</small>
|
||||
}
|
||||
</td>
|
||||
<td>@item.Quantity</td>
|
||||
<td><span data-inline-field="quantity" data-raw-value="@item.Quantity">@item.Quantity</span></td>
|
||||
<td>
|
||||
@item.SurfaceAreaSqFt.ToString("F2") @ViewBag.AreaUnit
|
||||
<br /><small class="text-muted">per item</small>
|
||||
@@ -487,8 +487,8 @@
|
||||
<strong class="text-success">@totalPowderNeeded.ToString("F2") lbs</strong>
|
||||
<br /><small class="text-muted">total batch</small>
|
||||
</td>
|
||||
<td>@item.UnitPrice.ToString("C")</td>
|
||||
<td><strong>@item.TotalPrice.ToString("C")</strong></td>
|
||||
<td><span data-inline-field="unitPrice" data-raw-value="@item.UnitPrice">@item.UnitPrice.ToString("C")</span></td>
|
||||
<td data-line-total><strong>@item.TotalPrice.ToString("C")</strong></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
@@ -1023,7 +1023,7 @@
|
||||
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span>Subtotal:</span>
|
||||
<strong>@Model.PricingBreakdown.SubtotalBeforeDiscount.ToString("C")</strong>
|
||||
<strong id="quote-subtotal">@Model.PricingBreakdown.SubtotalBeforeDiscount.ToString("C")</strong>
|
||||
</div>
|
||||
|
||||
@if (Model.PricingBreakdown.DiscountAmount > 0)
|
||||
@@ -1075,7 +1075,7 @@
|
||||
{
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span>Tax (@Model.PricingBreakdown.TaxPercent.ToString("G29")%):</span>
|
||||
<strong>@Model.PricingBreakdown.TaxAmount.ToString("C")</strong>
|
||||
<strong id="quote-tax">@Model.PricingBreakdown.TaxAmount.ToString("C")</strong>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1083,7 +1083,7 @@
|
||||
|
||||
<div class="d-flex justify-content-between mb-0">
|
||||
<h5>Total:</h5>
|
||||
<h5 class="text-primary"><strong>@Model.Total.ToString("C")</strong></h5>
|
||||
<h5 class="text-primary"><strong id="quote-total">@Model.Total.ToString("C")</strong></h5>
|
||||
</div>
|
||||
|
||||
@if (Model.PricingBreakdown.DiscountAmount > 0)
|
||||
@@ -2262,6 +2262,18 @@
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/customer-change.js" asp-append-version="true"></script>
|
||||
<script src="~/js/inline-item-edit.js" asp-append-version="true"></script>
|
||||
<script>
|
||||
window.inlineItemEdit = {
|
||||
patchUrl: '@Url.Action("PatchItem", "Quotes")',
|
||||
canEdit: true,
|
||||
totals: {
|
||||
subtotal: '#quote-subtotal',
|
||||
tax: '#quote-tax',
|
||||
total: '#quote-total'
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<script>
|
||||
function sendQuoteToAdHocEmail(quoteId) {
|
||||
const email = (document.getElementById('quoteAdHocEmailInput').value ?? '').trim();
|
||||
|
||||
@@ -525,7 +525,8 @@
|
||||
transferEfficiency = c.TransferEfficiency,
|
||||
powderCostPerLb = c.PowderCostPerLb,
|
||||
powderToOrder = c.PowderToOrder,
|
||||
notes = c.Notes
|
||||
notes = c.Notes,
|
||||
noExtraLayerCharge = c.NoExtraLayerCharge
|
||||
})
|
||||
})))
|
||||
</script>
|
||||
|
||||
@@ -10,4 +10,4 @@
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1152,3 +1152,14 @@ a.tag-index-badge:hover {
|
||||
}
|
||||
}
|
||||
.mw-lg { max-width: 640px; }
|
||||
|
||||
/* ── Inline item edit ───────────────────────────────────────── */
|
||||
.inline-editable:hover {
|
||||
text-decoration: underline dotted;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.inline-edit-input {
|
||||
display: inline-block;
|
||||
padding: 1px 4px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/// <summary>
|
||||
/// Shared inline-edit behaviour for quote, job, and invoice item rows.
|
||||
/// Activated when the page sets window.inlineItemEdit = { patchUrl, canEdit, totals }.
|
||||
/// totals: { subtotal, tax, total, finalPrice, balance } — CSS selectors, any subset.
|
||||
/// </summary>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const cfg = window.inlineItemEdit;
|
||||
if (!cfg || !cfg.canEdit) return;
|
||||
|
||||
function fmt(val) {
|
||||
return val.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||
}
|
||||
|
||||
function csrfToken() {
|
||||
return document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'alert alert-danger alert-permanent position-fixed bottom-0 end-0 m-3 shadow';
|
||||
el.style.zIndex = '9999';
|
||||
el.textContent = msg;
|
||||
document.body.appendChild(el);
|
||||
setTimeout(() => el.remove(), 4000);
|
||||
}
|
||||
|
||||
function updateTotals(data) {
|
||||
const t = cfg.totals || {};
|
||||
[
|
||||
[t.subtotal, data.subtotal],
|
||||
[t.tax, data.taxAmount],
|
||||
[t.total, data.total],
|
||||
[t.finalPrice, data.finalPrice],
|
||||
[t.balance, data.balanceDue],
|
||||
].forEach(([sel, val]) => {
|
||||
if (sel && val !== undefined && val !== null) {
|
||||
document.querySelectorAll(sel).forEach(el => { el.textContent = fmt(val); });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function makeEditable(span) {
|
||||
const field = span.dataset.inlineField;
|
||||
const row = span.closest('tr[data-item-id]');
|
||||
if (!row) return;
|
||||
const itemId = row.dataset.itemId;
|
||||
|
||||
const rawVal = span.dataset.rawValue ?? span.textContent.trim();
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.className = 'form-control form-control-sm inline-edit-input';
|
||||
if (field === 'description') {
|
||||
input.type = 'text';
|
||||
input.style.minWidth = '140px';
|
||||
} else {
|
||||
input.type = 'number';
|
||||
input.step = '0.01';
|
||||
input.min = '0';
|
||||
input.style.width = '80px';
|
||||
}
|
||||
input.value = rawVal;
|
||||
|
||||
// Stash current rendered markup so we can revert
|
||||
const savedHTML = span.innerHTML;
|
||||
span.innerHTML = '';
|
||||
span.appendChild(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
let committed = false;
|
||||
|
||||
function revert() {
|
||||
span.innerHTML = savedHTML;
|
||||
attachListeners(span);
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
if (committed) return;
|
||||
committed = true;
|
||||
|
||||
const newVal = input.value.trim();
|
||||
if (newVal === '' || (field !== 'description' && isNaN(parseFloat(newVal)))) {
|
||||
revert();
|
||||
return;
|
||||
}
|
||||
|
||||
// Read sibling raw values from other editable cells in the same row
|
||||
function siblingRaw(f) {
|
||||
const s = row.querySelector(`[data-inline-field="${f}"]`);
|
||||
if (!s) return null;
|
||||
// If that sibling is currently showing an input (concurrent edit, unlikely), fall back
|
||||
const inp = s.querySelector('input.inline-edit-input');
|
||||
if (inp) return inp.value;
|
||||
return s.dataset.rawValue ?? s.textContent.trim();
|
||||
}
|
||||
|
||||
const description = field === 'description' ? newVal : (siblingRaw('description') ?? '');
|
||||
const quantity = parseFloat(field === 'quantity' ? newVal : (siblingRaw('quantity') ?? '1'));
|
||||
const unitPrice = parseFloat(field === 'unitPrice' ? newVal : (siblingRaw('unitPrice') ?? '0'));
|
||||
|
||||
try {
|
||||
const resp = await fetch(cfg.patchUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'RequestVerificationToken': csrfToken()
|
||||
},
|
||||
body: JSON.stringify({ itemId: parseInt(itemId, 10), description, quantity, unitPrice })
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
showError(err.error ?? 'Could not save — try again.');
|
||||
revert();
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
|
||||
// Update this span's display and stored raw value
|
||||
if (field === 'description') {
|
||||
const strong = span.querySelector('strong');
|
||||
if (strong) { strong.textContent = newVal; }
|
||||
else { span.innerHTML = `<strong>${newVal}</strong>`; }
|
||||
span.dataset.rawValue = newVal;
|
||||
} else if (field === 'quantity') {
|
||||
span.dataset.rawValue = quantity;
|
||||
span.textContent = quantity % 1 === 0 ? quantity.toFixed(0) : quantity.toString();
|
||||
} else if (field === 'unitPrice') {
|
||||
span.dataset.rawValue = unitPrice;
|
||||
span.textContent = fmt(unitPrice);
|
||||
}
|
||||
|
||||
// Update line total cell
|
||||
const totalCell = row.querySelector('[data-line-total]');
|
||||
if (totalCell) totalCell.textContent = fmt(data.lineTotal);
|
||||
|
||||
// Update document-level totals
|
||||
updateTotals(data);
|
||||
|
||||
// Re-attach click listener for next edit
|
||||
attachListeners(span);
|
||||
|
||||
} catch {
|
||||
showError('Could not save — check your connection and try again.');
|
||||
revert();
|
||||
}
|
||||
}
|
||||
|
||||
input.addEventListener('blur', commit);
|
||||
input.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
|
||||
if (e.key === 'Escape') { committed = true; revert(); }
|
||||
});
|
||||
}
|
||||
|
||||
function attachListeners(span) {
|
||||
span.style.cursor = 'text';
|
||||
span.title = 'Click to edit';
|
||||
span.classList.add('inline-editable');
|
||||
span.addEventListener('click', () => makeEditable(span), { once: true });
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelectorAll('[data-inline-field]').forEach(attachListeners);
|
||||
});
|
||||
})();
|
||||
@@ -82,6 +82,21 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const ownerForm = hfc?.closest('form');
|
||||
if (ownerForm) {
|
||||
ownerForm.addEventListener('submit', writeHiddenFields, { capture: true });
|
||||
|
||||
// Save scroll position before the form causes a full-page reload so we can
|
||||
// restore it after the server redirects back to this page. Key is path-specific
|
||||
// so navigating away and back doesn't restore a stale position.
|
||||
const scrollKey = 'wizardScrollY:' + location.pathname;
|
||||
ownerForm.addEventListener('submit', () => {
|
||||
sessionStorage.setItem(scrollKey, String(Math.round(window.scrollY)));
|
||||
}, { capture: true });
|
||||
|
||||
// Restore on load — fire after layout is painted so scrollTo lands correctly.
|
||||
const savedY = sessionStorage.getItem(scrollKey);
|
||||
if (savedY !== null) {
|
||||
sessionStorage.removeItem(scrollKey);
|
||||
requestAnimationFrame(() => window.scrollTo({ top: parseInt(savedY, 10), behavior: 'instant' }));
|
||||
}
|
||||
}
|
||||
|
||||
// Close any open powder combobox or catalog lookup dropdown when clicking outside it
|
||||
@@ -2731,8 +2746,9 @@ function buildCardHtml(item, i) {
|
||||
const orderBadge = (!c.inventoryItemId && c.powderToOrder)
|
||||
? ` <span class="badge bg-warning text-dark" style="font-size:.65em;vertical-align:middle;" title="Custom powder — must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER ${parseFloat(c.powderToOrder).toFixed(2)} lbs</span>`
|
||||
: '';
|
||||
const coatNotes = c.notes ? `<span class="fst-italic ms-1 opacity-75">— ${escHtml(c.notes)}</span>` : '';
|
||||
return `<div style="font-size:.8rem;" class="text-muted mt-1">
|
||||
<i class="bi bi-layers me-1"></i><span class="fw-semibold">${escHtml(c.coatName || 'Coat')}</span>${color ? ` — ${color}${code}` : ''}${orderBadge}
|
||||
<i class="bi bi-layers me-1"></i><span class="fw-semibold">${escHtml(c.coatName || 'Coat')}</span>${color ? ` — ${color}${code}` : ''}${orderBadge}${coatNotes}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
@@ -3414,7 +3430,7 @@ function loadItemsFromTemplate(templateItems) {
|
||||
coverageSqFtPerLb: c.coverageSqFtPerLb || 30,
|
||||
transferEfficiency: c.transferEfficiency || 65,
|
||||
powderCostPerLb: c.powderCostPerLb || null,
|
||||
noExtraLayerCharge: false
|
||||
noExtraLayerCharge: !!c.noExtraLayerCharge
|
||||
})),
|
||||
prepServices: (ti.prepServices || []).map(p => ({
|
||||
prepServiceId: p.prepServiceId,
|
||||
|
||||
Reference in New Issue
Block a user