Compare commits
21 Commits
v2.15.0
...
v2026.05.19
| Author | SHA1 | Date | |
|---|---|---|---|
| 31c5746e5b | |||
| 3f9ac27afa | |||
| df504674e9 | |||
| 07796b05c8 | |||
| 2bf8871892 | |||
| 8a0a564885 | |||
| dd4785b048 | |||
| e185e3b7e3 | |||
| 8acbc8605d | |||
| 485f0b69c8 | |||
| f380c152ca | |||
| 79c8c7e6a4 | |||
| 6cf355071b | |||
| ebd474ae81 | |||
| 3c390a2e05 | |||
| 0df2353d4f | |||
| be0a5b26e2 | |||
| 36680eced9 | |||
| 27aa4e0ea6 | |||
| b2d6fae400 | |||
| 3a1928f9bf |
@@ -137,6 +137,13 @@ public class CreateJobDto
|
|||||||
[Display(Name = "Oven")]
|
[Display(Name = "Oven")]
|
||||||
public int? OvenCostId { get; set; }
|
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")]
|
[Required(ErrorMessage = "Description is required")]
|
||||||
[StringLength(2000, ErrorMessage = "Description cannot exceed 2000 characters")]
|
[StringLength(2000, ErrorMessage = "Description cannot exceed 2000 characters")]
|
||||||
[Display(Name = "Description")]
|
[Display(Name = "Description")]
|
||||||
@@ -208,6 +215,16 @@ public class UpdateJobDto
|
|||||||
[Display(Name = "Assigned Worker")]
|
[Display(Name = "Assigned Worker")]
|
||||||
public string? AssignedUserId { get; set; }
|
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")]
|
[Required(ErrorMessage = "Description is required")]
|
||||||
[StringLength(2000, ErrorMessage = "Description cannot exceed 2000 characters")]
|
[StringLength(2000, ErrorMessage = "Description cannot exceed 2000 characters")]
|
||||||
[Display(Name = "Description")]
|
[Display(Name = "Description")]
|
||||||
@@ -381,6 +398,7 @@ public class JobItemCoatDto
|
|||||||
public decimal? PowderCostPerLb { get; set; }
|
public decimal? PowderCostPerLb { get; set; }
|
||||||
public decimal? PowderToOrder { get; set; }
|
public decimal? PowderToOrder { get; set; }
|
||||||
public decimal? ActualPowderUsedLbs { get; set; } // Filled during job completion
|
public decimal? ActualPowderUsedLbs { get; set; } // Filled during job completion
|
||||||
|
public bool NoExtraLayerCharge { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,7 +407,7 @@ public class CompleteJobDto
|
|||||||
{
|
{
|
||||||
public int JobId { get; set; }
|
public int JobId { get; set; }
|
||||||
public decimal? ActualTimeSpentHours { get; set; }
|
public decimal? ActualTimeSpentHours { get; set; }
|
||||||
public List<JobItemCoatUsageDto> CoatUsages { get; set; } = new();
|
public List<JobPowderUsageDto> PowderUsages { get; set; } = new();
|
||||||
public bool SendEmailToCustomer { get; set; } = false;
|
public bool SendEmailToCustomer { get; set; } = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,10 +418,10 @@ public class SendJobSmsRequest
|
|||||||
public string Message { get; set; } = string.Empty;
|
public string Message { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
// DTO for tracking actual powder usage per coat
|
// DTO for tracking actual powder usage per inventory item (color) for the whole job
|
||||||
public class JobItemCoatUsageDto
|
public class JobPowderUsageDto
|
||||||
{
|
{
|
||||||
public int JobItemCoatId { get; set; }
|
public int InventoryItemId { get; set; }
|
||||||
public decimal? ActualPowderUsedLbs { get; set; }
|
public decimal? ActualPowderUsedLbs { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -801,6 +801,7 @@ public class QuoteItemCoatDto
|
|||||||
public decimal CoatMaterialCost { get; set; }
|
public decimal CoatMaterialCost { get; set; }
|
||||||
public decimal CoatLaborCost { get; set; }
|
public decimal CoatLaborCost { get; set; }
|
||||||
public decimal CoatTotalCost { get; set; }
|
public decimal CoatTotalCost { get; set; }
|
||||||
|
public bool NoExtraLayerCharge { get; set; }
|
||||||
public string? Notes { 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.
|
/// Alert company staff when a Stripe chargeback (dispute) is opened on an invoice payment.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task NotifyChargebackAlertAsync(Invoice invoice, string disputeId, decimal amount, string reason);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,26 @@ using PowderCoating.Core.Entities;
|
|||||||
|
|
||||||
namespace PowderCoating.Application.Services;
|
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
|
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)
|
public JobItem CreateJobItem(CreateQuoteItemDto source, int jobId, int companyId, QuoteItemPricingResult pricing, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(source);
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
@@ -42,6 +60,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
createdAtUtc);
|
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)
|
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(source);
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
@@ -62,7 +85,8 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
TransferEfficiency = c.TransferEfficiency,
|
TransferEfficiency = c.TransferEfficiency,
|
||||||
PowderCostPerLb = c.PowderCostPerLb,
|
PowderCostPerLb = c.PowderCostPerLb,
|
||||||
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
|
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
|
||||||
Notes = c.Notes
|
Notes = c.Notes,
|
||||||
|
NoExtraLayerCharge = c.NoExtraLayerCharge
|
||||||
},
|
},
|
||||||
jobItemId,
|
jobItemId,
|
||||||
companyId,
|
companyId,
|
||||||
@@ -70,6 +94,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
.ToList() ?? [];
|
.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)
|
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(source);
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
@@ -85,6 +114,13 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
createdAtUtc);
|
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)
|
public JobItem CreateJobItem(QuoteItem source, int jobId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(source);
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
@@ -128,6 +164,12 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
createdAtUtc);
|
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)
|
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(source);
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
@@ -151,7 +193,8 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
TransferEfficiency = c.TransferEfficiency,
|
TransferEfficiency = c.TransferEfficiency,
|
||||||
PowderCostPerLb = c.PowderCostPerLb,
|
PowderCostPerLb = c.PowderCostPerLb,
|
||||||
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
|
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
|
||||||
Notes = c.Notes
|
Notes = c.Notes,
|
||||||
|
NoExtraLayerCharge = c.NoExtraLayerCharge
|
||||||
},
|
},
|
||||||
jobItemId,
|
jobItemId,
|
||||||
companyId,
|
companyId,
|
||||||
@@ -160,6 +203,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
.ToList() ?? [];
|
.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)
|
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(source);
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
@@ -175,6 +221,12 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
createdAtUtc);
|
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)
|
public JobItem CreateJobItem(JobItem source, int jobId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(source);
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
@@ -214,6 +266,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
createdAtUtc);
|
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)
|
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(source);
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
@@ -234,7 +291,8 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
TransferEfficiency = c.TransferEfficiency,
|
TransferEfficiency = c.TransferEfficiency,
|
||||||
PowderCostPerLb = c.PowderCostPerLb,
|
PowderCostPerLb = c.PowderCostPerLb,
|
||||||
PowderToOrder = c.PowderToOrder,
|
PowderToOrder = c.PowderToOrder,
|
||||||
Notes = c.Notes
|
Notes = c.Notes,
|
||||||
|
NoExtraLayerCharge = c.NoExtraLayerCharge
|
||||||
},
|
},
|
||||||
jobItemId,
|
jobItemId,
|
||||||
companyId,
|
companyId,
|
||||||
@@ -242,6 +300,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
.ToList() ?? [];
|
.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)
|
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(source);
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
@@ -257,6 +318,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
createdAtUtc);
|
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)
|
private static JobItem BuildJobItem(JobItemSeed seed, int jobId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
return new JobItem
|
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)
|
private static JobItemCoat BuildJobItemCoat(JobItemCoatSeed seed, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
return new JobItemCoat
|
return new JobItemCoat
|
||||||
@@ -310,11 +378,17 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
PowderCostPerLb = seed.PowderCostPerLb,
|
PowderCostPerLb = seed.PowderCostPerLb,
|
||||||
PowderToOrder = seed.PowderToOrder,
|
PowderToOrder = seed.PowderToOrder,
|
||||||
Notes = seed.Notes,
|
Notes = seed.Notes,
|
||||||
|
NoExtraLayerCharge = seed.NoExtraLayerCharge,
|
||||||
CompanyId = companyId,
|
CompanyId = companyId,
|
||||||
CreatedAt = createdAtUtc
|
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)
|
private static IReadOnlyList<JobItemPrepService> BuildJobItemPrepServices(IEnumerable<JobItemPrepServiceSeed>? seeds, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
return seeds?
|
return seeds?
|
||||||
@@ -330,6 +404,18 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
.ToList() ?? [];
|
.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)
|
private static decimal? CalculatePowderToOrder(decimal? storedPowderToOrder, decimal surfaceAreaSqFt, decimal quantity, decimal coverageSqFtPerLb, decimal transferEfficiency)
|
||||||
{
|
{
|
||||||
if (storedPowderToOrder.HasValue && storedPowderToOrder.Value > 0)
|
if (storedPowderToOrder.HasValue && storedPowderToOrder.Value > 0)
|
||||||
@@ -343,6 +429,12 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
return Math.Round((surfaceAreaSqFt * quantity) / (coverage * efficiency), 2);
|
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(
|
private static (string? ColorName, string? ColorCode, string? Finish) ResolveCoatAppearance(
|
||||||
string? colorName,
|
string? colorName,
|
||||||
string? colorCode,
|
string? colorCode,
|
||||||
@@ -355,6 +447,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
return (inventoryItem.Name, inventoryItem.ColorCode, inventoryItem.Finish);
|
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
|
private sealed class JobItemSeed
|
||||||
{
|
{
|
||||||
public string Description { get; init; } = string.Empty;
|
public string Description { get; init; } = string.Empty;
|
||||||
@@ -385,6 +482,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
public int? AiPredictionId { get; init; }
|
public int? AiPredictionId { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Intermediate value object for coat creation — see <see cref="JobItemSeed"/> for rationale.</summary>
|
||||||
private sealed class JobItemCoatSeed
|
private sealed class JobItemCoatSeed
|
||||||
{
|
{
|
||||||
public string CoatName { get; init; } = string.Empty;
|
public string CoatName { get; init; } = string.Empty;
|
||||||
@@ -399,8 +497,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
public decimal? PowderCostPerLb { get; init; }
|
public decimal? PowderCostPerLb { get; init; }
|
||||||
public decimal? PowderToOrder { get; init; }
|
public decimal? PowderToOrder { get; init; }
|
||||||
public string? Notes { 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
|
private sealed class JobItemPrepServiceSeed
|
||||||
{
|
{
|
||||||
public int PrepServiceId { get; init; }
|
public int PrepServiceId { get; init; }
|
||||||
|
|||||||
@@ -6,6 +6,20 @@ using Microsoft.Extensions.Logging;
|
|||||||
|
|
||||||
namespace PowderCoating.Application.Services;
|
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
|
public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||||
{
|
{
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
@@ -25,6 +39,11 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
_logger = logger;
|
_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)
|
public void ApplyPricingSnapshot(Quote quote, QuotePricingResult pricingResult)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(quote);
|
ArgumentNullException.ThrowIfNull(quote);
|
||||||
@@ -56,6 +75,12 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
quote.Total = pricingResult.Total;
|
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(
|
public async Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync(
|
||||||
IEnumerable<CreateQuoteItemDto> itemDtos,
|
IEnumerable<CreateQuoteItemDto> itemDtos,
|
||||||
int quoteId,
|
int quoteId,
|
||||||
@@ -80,6 +105,13 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
return items;
|
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)
|
private async Task ApplyPricingAsync(QuoteItem item, CreateQuoteItemDto itemDto, int companyId, decimal? ovenRateOverride)
|
||||||
{
|
{
|
||||||
if (itemDto.IsAiItem && itemDto.ManualUnitPrice.HasValue && itemDto.ManualUnitPrice.Value > 0)
|
if (itemDto.IsAiItem && itemDto.ManualUnitPrice.HasValue && itemDto.ManualUnitPrice.Value > 0)
|
||||||
@@ -127,6 +159,12 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
ApplyCalculatedPricing(item, pricing);
|
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)
|
private async Task<List<QuoteItemCoat>> BuildQuoteItemCoatsAsync(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
if (itemDto.Coats == null || itemDto.Coats.Count == 0)
|
if (itemDto.Coats == null || itemDto.Coats.Count == 0)
|
||||||
@@ -158,6 +196,7 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
return coats;
|
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)
|
private static List<QuoteItemPrepService> BuildQuoteItemPrepServices(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
if (itemDto.PrepServices == null || itemDto.PrepServices.Count == 0)
|
if (itemDto.PrepServices == null || itemDto.PrepServices.Count == 0)
|
||||||
@@ -175,6 +214,11 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
.ToList();
|
.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)
|
private static QuoteItem BuildQuoteItem(CreateQuoteItemDto itemDto, int quoteId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
return new QuoteItem
|
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)
|
private static QuoteItemCoat BuildQuoteItemCoat(CreateQuoteItemCoatDto coatDto, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
return new QuoteItemCoat
|
return new QuoteItemCoat
|
||||||
@@ -225,6 +270,10 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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)
|
private static void ApplyCalculatedPricing(QuoteItem item, QuoteItemPricingResult pricing)
|
||||||
{
|
{
|
||||||
item.UnitPrice = pricing.UnitPrice;
|
item.UnitPrice = pricing.UnitPrice;
|
||||||
@@ -234,6 +283,13 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
item.ItemEquipmentCost = pricing.EquipmentCost;
|
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)
|
private async Task UpdateAiPredictionOverrideAsync(CreateQuoteItemDto itemDto, decimal finalUnitPrice)
|
||||||
{
|
{
|
||||||
if (!itemDto.AiPredictionId.HasValue) return;
|
if (!itemDto.AiPredictionId.HasValue) return;
|
||||||
@@ -247,6 +303,23 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
prediction.UpdatedAt = DateTime.UtcNow;
|
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)
|
private async Task<int?> CreateIncomingInventoryItemAsync(CreateQuoteItemCoatDto coatDto, int companyId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -95,6 +95,12 @@ public class Appointment : BaseEntity
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int ReminderMinutesBefore { get; set; } = 30;
|
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
|
// Navigation Properties
|
||||||
public virtual Customer? Customer { get; set; }
|
public virtual Customer? Customer { get; set; }
|
||||||
public virtual Job? Job { get; set; }
|
public virtual Job? Job { get; set; }
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ public class JobItemCoat : BaseEntity
|
|||||||
public string? PowderReceivedByUserId { get; set; }
|
public string? PowderReceivedByUserId { get; set; }
|
||||||
public decimal? PowderReceivedLbs { 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
|
// Notes
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ public class QuoteItemCoat : BaseEntity
|
|||||||
public decimal CoatLaborCost { get; set; }
|
public decimal CoatLaborCost { get; set; }
|
||||||
public decimal CoatTotalCost { 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
|
// Notes
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -20,5 +20,7 @@ public enum NotificationType
|
|||||||
SmsInboundStop = 12,
|
SmsInboundStop = 12,
|
||||||
SmsInboundHelp = 13,
|
SmsInboundHelp = 13,
|
||||||
AdminEmail = 14,
|
AdminEmail = 14,
|
||||||
SmsInboundStart = 15
|
SmsInboundStart = 15,
|
||||||
|
AppointmentReminder = 17,
|
||||||
|
AppointmentReminderStaff = 18
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,11 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
if (companyIdClaim != null && int.TryParse(companyIdClaim, out int companyId))
|
if (companyIdClaim != null && int.TryParse(companyIdClaim, out int companyId))
|
||||||
return companyId;
|
return companyId;
|
||||||
|
|
||||||
return null;
|
// Authenticated but CompanyId claim is missing or invalid.
|
||||||
|
// Return 0 (never a real company ID) so the global filter generates
|
||||||
|
// "CompanyId = 0" which matches nothing — prevents null-comparison
|
||||||
|
// ambiguity from leaking cross-tenant rows.
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,8 +133,11 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
|
// No HTTP context means background service, hosted service, or unit test — bypass tenant filter
|
||||||
|
if (_httpContextAccessor?.HttpContext == null) return true;
|
||||||
if (!IsSuperAdmin) return false;
|
if (!IsSuperAdmin) return false;
|
||||||
return CurrentCompanyId == null || CurrentCompanyId == 1;
|
// CompanyId == 0 means no claim was present (break-glass / test SuperAdmins) — treat as platform admin
|
||||||
|
return CurrentCompanyId == null || CurrentCompanyId == 0 || CurrentCompanyId == 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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 />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.UpdateData(
|
// These UpdateData calls were generated from an existing live database.
|
||||||
table: "PricingTiers",
|
// On a fresh install the PricingTiers table and its seed rows may not exist yet
|
||||||
keyColumn: "Id",
|
// (seeding is manual via Platform Management → Seed Data), so guard each update.
|
||||||
keyValue: 1,
|
migrationBuilder.Sql(@"
|
||||||
column: "CreatedAt",
|
IF OBJECT_ID(N'[PricingTiers]', N'U') IS NOT NULL
|
||||||
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7851));
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 1)
|
||||||
migrationBuilder.UpdateData(
|
UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377851Z' WHERE [Id] = 1;
|
||||||
table: "PricingTiers",
|
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 2)
|
||||||
keyColumn: "Id",
|
UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377856Z' WHERE [Id] = 2;
|
||||||
keyValue: 2,
|
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 3)
|
||||||
column: "CreatedAt",
|
UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377858Z' WHERE [Id] = 3;
|
||||||
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7856));
|
END
|
||||||
|
");
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "PricingTiers",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: 3,
|
|
||||||
column: "CreatedAt",
|
|
||||||
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7858));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <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")
|
b.Property<int>("ReminderMinutesBefore")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ReminderSentAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
b.Property<DateTime>("ScheduledEndTime")
|
b.Property<DateTime>("ScheduledEndTime")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
@@ -4252,9 +4255,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<decimal>("ShopSuppliesPercent")
|
b.Property<decimal>("ShopSuppliesPercent")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<int?>("ShopWorkerId")
|
|
||||||
.HasColumnType("int");
|
|
||||||
|
|
||||||
b.Property<string>("SpecialInstructions")
|
b.Property<string>("SpecialInstructions")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -4296,8 +4296,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.HasIndex("ScheduledDate");
|
b.HasIndex("ScheduledDate");
|
||||||
|
|
||||||
b.HasIndex("ShopWorkerId");
|
|
||||||
|
|
||||||
b.HasIndex("CompanyId", "CustomerId")
|
b.HasIndex("CompanyId", "CustomerId")
|
||||||
.HasDatabaseName("IX_Jobs_CompanyId_CustomerId");
|
.HasDatabaseName("IX_Jobs_CompanyId_CustomerId");
|
||||||
|
|
||||||
@@ -4620,6 +4618,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<int>("JobItemId")
|
b.Property<int>("JobItemId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<bool>("NoExtraLayerCharge")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<string>("Notes")
|
b.Property<string>("Notes")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -5439,9 +5440,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("Notes")
|
b.Property<string>("Notes")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<int?>("ShopWorkerId")
|
|
||||||
.HasColumnType("int");
|
|
||||||
|
|
||||||
b.Property<string>("Stage")
|
b.Property<string>("Stage")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -5464,8 +5462,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.HasIndex("JobId");
|
b.HasIndex("JobId");
|
||||||
|
|
||||||
b.HasIndex("ShopWorkerId");
|
|
||||||
|
|
||||||
b.ToTable("JobTimeEntries");
|
b.ToTable("JobTimeEntries");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -5789,9 +5785,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<DateTime>("ScheduledDate")
|
b.Property<DateTime>("ScheduledDate")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
b.Property<int?>("ShopWorkerId")
|
|
||||||
.HasColumnType("int");
|
|
||||||
|
|
||||||
b.Property<int>("Status")
|
b.Property<int>("Status")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -5822,8 +5815,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.HasIndex("ScheduledDate");
|
b.HasIndex("ScheduledDate");
|
||||||
|
|
||||||
b.HasIndex("ShopWorkerId");
|
|
||||||
|
|
||||||
b.HasIndex("Status");
|
b.HasIndex("Status");
|
||||||
|
|
||||||
b.HasIndex("CompanyId", "ScheduledDate")
|
b.HasIndex("CompanyId", "ScheduledDate")
|
||||||
@@ -6720,7 +6711,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
CompanyId = 0,
|
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",
|
Description = "Standard pricing for regular customers",
|
||||||
DiscountPercent = 0m,
|
DiscountPercent = 0m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6731,7 +6722,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
CompanyId = 0,
|
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",
|
Description = "5% discount for preferred customers",
|
||||||
DiscountPercent = 5m,
|
DiscountPercent = 5m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6742,7 +6733,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
CompanyId = 0,
|
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",
|
Description = "10% discount for premium customers",
|
||||||
DiscountPercent = 10m,
|
DiscountPercent = 10m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -7417,6 +7408,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<bool>("IsDeleted")
|
b.Property<bool>("IsDeleted")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("NoExtraLayerCharge")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<string>("Notes")
|
b.Property<string>("Notes")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -8019,111 +8013,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.ToTable("ReworkRecords");
|
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 =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.StripeWebhookEvent", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
@@ -9541,10 +9430,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
.HasForeignKey("PowderCoating.Core.Entities.Job", "QuoteId")
|
.HasForeignKey("PowderCoating.Core.Entities.Job", "QuoteId")
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
b.HasOne("PowderCoating.Core.Entities.ShopWorker", null)
|
|
||||||
.WithMany("AssignedJobs")
|
|
||||||
.HasForeignKey("ShopWorkerId");
|
|
||||||
|
|
||||||
b.Navigation("AssignedUser");
|
b.Navigation("AssignedUser");
|
||||||
|
|
||||||
b.Navigation("Customer");
|
b.Navigation("Customer");
|
||||||
@@ -9847,13 +9732,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.HasOne("PowderCoating.Core.Entities.ShopWorker", "Worker")
|
|
||||||
.WithMany("TimeEntries")
|
|
||||||
.HasForeignKey("ShopWorkerId");
|
|
||||||
|
|
||||||
b.Navigation("Job");
|
b.Navigation("Job");
|
||||||
|
|
||||||
b.Navigation("Worker");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.JournalEntry", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.JournalEntry", b =>
|
||||||
@@ -9924,10 +9803,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("RecurrenceParentId");
|
.HasForeignKey("RecurrenceParentId");
|
||||||
|
|
||||||
b.HasOne("PowderCoating.Core.Entities.ShopWorker", null)
|
|
||||||
.WithMany("AssignedMaintenanceTasks")
|
|
||||||
.HasForeignKey("ShopWorkerId");
|
|
||||||
|
|
||||||
b.Navigation("AssignedUser");
|
b.Navigation("AssignedUser");
|
||||||
|
|
||||||
b.Navigation("Equipment");
|
b.Navigation("Equipment");
|
||||||
@@ -10411,15 +10286,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Navigation("ReworkJob");
|
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 =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.Vendor", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("PowderCoating.Core.Entities.Company", null)
|
b.HasOne("PowderCoating.Core.Entities.Company", null)
|
||||||
@@ -10582,8 +10448,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.Navigation("Quotes");
|
b.Navigation("Quotes");
|
||||||
|
|
||||||
b.Navigation("ShopWorkers");
|
|
||||||
|
|
||||||
b.Navigation("Users");
|
b.Navigation("Users");
|
||||||
|
|
||||||
b.Navigation("Vendors");
|
b.Navigation("Vendors");
|
||||||
@@ -10749,15 +10613,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Navigation("Quotes");
|
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 =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.Vendor", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("BillPayments");
|
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)
|
// 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)",
|
"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>"
|
"<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)
|
public static (string? Subject, string Body)? Get(NotificationType type, NotificationChannel channel)
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,10 +60,11 @@ public class AccountingExportController : Controller
|
|||||||
{
|
{
|
||||||
var start = startDate.Date;
|
var start = startDate.Date;
|
||||||
var end = endDate.Date.AddDays(1).AddTicks(-1);
|
var end = endDate.Date.AddDays(1).AddTicks(-1);
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
// ── Load data ─────────────────────────────────────────────────────────
|
// ── Load data ─────────────────────────────────────────────────────────
|
||||||
var invoices = (await _unitOfWork.Invoices.FindAsync(
|
var invoices = (await _unitOfWork.Invoices.FindAsync(
|
||||||
i => i.InvoiceDate >= start && i.InvoiceDate <= end,
|
i => i.CompanyId == companyId && i.InvoiceDate >= start && i.InvoiceDate <= end,
|
||||||
false,
|
false,
|
||||||
i => i.InvoiceItems,
|
i => i.InvoiceItems,
|
||||||
i => i.Payments,
|
i => i.Payments,
|
||||||
@@ -72,7 +73,7 @@ public class AccountingExportController : Controller
|
|||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var expenses = (await _unitOfWork.Expenses.FindAsync(
|
var expenses = (await _unitOfWork.Expenses.FindAsync(
|
||||||
e => e.Date >= start && e.Date <= end,
|
e => e.CompanyId == companyId && e.Date >= start && e.Date <= end,
|
||||||
false,
|
false,
|
||||||
e => e.Vendor,
|
e => e.Vendor,
|
||||||
e => e.ExpenseAccount,
|
e => e.ExpenseAccount,
|
||||||
@@ -82,7 +83,7 @@ public class AccountingExportController : Controller
|
|||||||
|
|
||||||
var bills = await _unitOfWork.Bills.GetForDateRangeAsync(start, end);
|
var bills = await _unitOfWork.Bills.GetForDateRangeAsync(start, end);
|
||||||
|
|
||||||
var customers = (await _unitOfWork.Customers.GetAllAsync())
|
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId))
|
||||||
.OrderBy(c => c.CompanyName ?? c.ContactFirstName)
|
.OrderBy(c => c.CompanyName ?? c.ContactFirstName)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
|||||||
@@ -381,9 +381,15 @@ public class AppointmentsController : Controller
|
|||||||
return View(dto);
|
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);
|
_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
|
// Update
|
||||||
await _unitOfWork.Appointments.UpdateAsync(appointment);
|
await _unitOfWork.Appointments.UpdateAsync(appointment);
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
@@ -486,9 +492,12 @@ public class AppointmentsController : Controller
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var events = new List<CalendarEventDto>();
|
var events = new List<CalendarEventDto>();
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
// 1. Fetch appointments in date range
|
// 1. Fetch appointments in date range
|
||||||
var allAppointments = await _unitOfWork.Appointments.GetAllAsync(false,
|
var allAppointments = await _unitOfWork.Appointments.FindAsync(
|
||||||
|
a => a.CompanyId == companyId,
|
||||||
|
false,
|
||||||
a => a.Customer,
|
a => a.Customer,
|
||||||
a => a.AppointmentType,
|
a => a.AppointmentType,
|
||||||
a => a.AppointmentStatus);
|
a => a.AppointmentStatus);
|
||||||
@@ -501,7 +510,9 @@ public class AppointmentsController : Controller
|
|||||||
events.AddRange(appointmentEvents);
|
events.AddRange(appointmentEvents);
|
||||||
|
|
||||||
// 2. Fetch maintenance records in date range
|
// 2. Fetch maintenance records in date range
|
||||||
var allMaintenanceRecords = await _unitOfWork.MaintenanceRecords.GetAllAsync(false,
|
var allMaintenanceRecords = await _unitOfWork.MaintenanceRecords.FindAsync(
|
||||||
|
m => m.CompanyId == companyId,
|
||||||
|
false,
|
||||||
m => m.Equipment);
|
m => m.Equipment);
|
||||||
|
|
||||||
var maintenanceRecords = allMaintenanceRecords
|
var maintenanceRecords = allMaintenanceRecords
|
||||||
@@ -539,7 +550,9 @@ public class AppointmentsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Fetch jobs and add as all-day events
|
// 3. Fetch jobs and add as all-day events
|
||||||
var allJobs = await _unitOfWork.Jobs.GetAllAsync(false,
|
var allJobs = await _unitOfWork.Jobs.FindAsync(
|
||||||
|
j => j.CompanyId == companyId,
|
||||||
|
false,
|
||||||
j => j.Customer,
|
j => j.Customer,
|
||||||
j => j.JobStatus);
|
j => j.JobStatus);
|
||||||
|
|
||||||
@@ -718,6 +731,8 @@ public class AppointmentsController : Controller
|
|||||||
var duration = appointment.ScheduledEndTime - appointment.ScheduledStartTime;
|
var duration = appointment.ScheduledEndTime - appointment.ScheduledStartTime;
|
||||||
appointment.ScheduledStartTime = start;
|
appointment.ScheduledStartTime = start;
|
||||||
appointment.ScheduledEndTime = end;
|
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.Appointments.UpdateAsync(appointment);
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
@@ -746,13 +761,16 @@ public class AppointmentsController : Controller
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var terminalCodes = new[] { AppConstants.StatusCodes.Job.Completed, AppConstants.StatusCodes.Job.Delivered, AppConstants.StatusCodes.Job.Cancelled };
|
var terminalCodes = new[] { AppConstants.StatusCodes.Job.Completed, AppConstants.StatusCodes.Job.Delivered, AppConstants.StatusCodes.Job.Cancelled };
|
||||||
var allJobs = await _unitOfWork.Jobs.GetAllAsync(false,
|
var calCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allJobs = await _unitOfWork.Jobs.FindAsync(
|
||||||
|
j => j.CompanyId == calCompanyId,
|
||||||
|
false,
|
||||||
j => j.Customer, j => j.JobStatus, j => j.JobItems);
|
j => j.Customer, j => j.JobStatus, j => j.JobItems);
|
||||||
|
|
||||||
// Load coats separately — filter by JobItemId using already-loaded item IDs
|
// Load coats separately — filter by JobItemId using already-loaded item IDs
|
||||||
var jobItemIds = allJobs.SelectMany(j => j.JobItems.Select(i => i.Id)).ToList();
|
var jobItemIds = allJobs.SelectMany(j => j.JobItems.Select(i => i.Id)).ToList();
|
||||||
var allCoats = await _unitOfWork.JobItemCoats.FindAsync(
|
var allCoats = await _unitOfWork.JobItemCoats.FindAsync(
|
||||||
c => jobItemIds.Contains(c.JobItemId));
|
c => jobItemIds.Contains(c.JobItemId) && c.CompanyId == calCompanyId);
|
||||||
|
|
||||||
var coatsByItemId = allCoats
|
var coatsByItemId = allCoats
|
||||||
.Where(c => !c.IsDeleted)
|
.Where(c => !c.IsDeleted)
|
||||||
@@ -891,7 +909,9 @@ public class AppointmentsController : Controller
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task PopulateCreateDropdowns()
|
private async Task PopulateCreateDropdowns()
|
||||||
{
|
{
|
||||||
var customers = await _unitOfWork.Customers.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
|
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
|
||||||
var customerList = customers.Select(c => new
|
var customerList = customers.Select(c => new
|
||||||
{
|
{
|
||||||
c.Id,
|
c.Id,
|
||||||
@@ -903,19 +923,16 @@ public class AppointmentsController : Controller
|
|||||||
.ToList();
|
.ToList();
|
||||||
ViewBag.Customers = new SelectList(customerList, "Id", "DisplayName");
|
ViewBag.Customers = new SelectList(customerList, "Id", "DisplayName");
|
||||||
|
|
||||||
// Use cached appointment types
|
|
||||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
|
||||||
var types = await _lookupCache.GetAppointmentTypeLookupsAsync(companyId);
|
var types = await _lookupCache.GetAppointmentTypeLookupsAsync(companyId);
|
||||||
ViewBag.AppointmentTypes = new SelectList(types.Where(t => t.IsActive).OrderBy(t => t.DisplayOrder), "Id", "DisplayName");
|
ViewBag.AppointmentTypes = new SelectList(types.Where(t => t.IsActive).OrderBy(t => t.DisplayOrder), "Id", "DisplayName");
|
||||||
|
|
||||||
var companyIdForWorkers = _tenantContext.GetCurrentCompanyId() ?? 0;
|
|
||||||
var workers = await _userManager.Users
|
var workers = await _userManager.Users
|
||||||
.Where(u => u.CompanyId == companyIdForWorkers && u.IsActive && u.CompanyRole != null)
|
.Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null)
|
||||||
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
|
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
ViewBag.Workers = new SelectList(workers.Select(u => new { u.Id, FullName = u.FullName }), "Id", "FullName");
|
ViewBag.Workers = new SelectList(workers.Select(u => new { u.Id, FullName = u.FullName }), "Id", "FullName");
|
||||||
|
|
||||||
var jobs = await _unitOfWork.Jobs.GetAllAsync();
|
var jobs = await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId);
|
||||||
ViewBag.Jobs = new SelectList(jobs.OrderBy(j => j.JobNumber), "Id", "JobNumber");
|
ViewBag.Jobs = new SelectList(jobs.OrderBy(j => j.JobNumber), "Id", "JobNumber");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,15 +27,18 @@ namespace PowderCoating.Web.Controllers
|
|||||||
{
|
{
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
private readonly IMapper _mapper;
|
private readonly IMapper _mapper;
|
||||||
|
private readonly ITenantContext _tenantContext;
|
||||||
private readonly ILogger<CatalogCategoriesController> _logger;
|
private readonly ILogger<CatalogCategoriesController> _logger;
|
||||||
|
|
||||||
public CatalogCategoriesController(
|
public CatalogCategoriesController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
IMapper mapper,
|
IMapper mapper,
|
||||||
|
ITenantContext tenantContext,
|
||||||
ILogger<CatalogCategoriesController> logger)
|
ILogger<CatalogCategoriesController> logger)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_mapper = mapper;
|
_mapper = mapper;
|
||||||
|
_tenantContext = tenantContext;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,8 +55,9 @@ namespace PowderCoating.Web.Controllers
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var indexCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
var categories = await _unitOfWork.CatalogCategories
|
var categories = await _unitOfWork.CatalogCategories
|
||||||
.GetAllAsync(false,
|
.FindAsync(c => c.CompanyId == indexCompanyId, false,
|
||||||
c => c.ParentCategory,
|
c => c.ParentCategory,
|
||||||
c => c.SubCategories,
|
c => c.SubCategories,
|
||||||
c => c.Items);
|
c => c.Items);
|
||||||
@@ -164,7 +168,8 @@ namespace PowderCoating.Web.Controllers
|
|||||||
if (ModelState.IsValid)
|
if (ModelState.IsValid)
|
||||||
{
|
{
|
||||||
// Check for duplicate category name under the same parent (case-insensitive)
|
// Check for duplicate category name under the same parent (case-insensitive)
|
||||||
var allCategories = await _unitOfWork.CatalogCategories.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allCategories = await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == companyId);
|
||||||
var existingCategory = allCategories.FirstOrDefault(c =>
|
var existingCategory = allCategories.FirstOrDefault(c =>
|
||||||
c.Name.Equals(dto.Name.Trim(), StringComparison.OrdinalIgnoreCase) &&
|
c.Name.Equals(dto.Name.Trim(), StringComparison.OrdinalIgnoreCase) &&
|
||||||
c.ParentCategoryId == dto.ParentCategoryId);
|
c.ParentCategoryId == dto.ParentCategoryId);
|
||||||
@@ -272,7 +277,8 @@ namespace PowderCoating.Web.Controllers
|
|||||||
|
|
||||||
if (nameChanged || parentChanged)
|
if (nameChanged || parentChanged)
|
||||||
{
|
{
|
||||||
var allCategories = await _unitOfWork.CatalogCategories.GetAllAsync();
|
var editCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allCategories = await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == editCompanyId);
|
||||||
var existingCategory = allCategories.FirstOrDefault(c =>
|
var existingCategory = allCategories.FirstOrDefault(c =>
|
||||||
c.Id != id &&
|
c.Id != id &&
|
||||||
c.Name.Equals(dto.Name.Trim(), StringComparison.OrdinalIgnoreCase) &&
|
c.Name.Equals(dto.Name.Trim(), StringComparison.OrdinalIgnoreCase) &&
|
||||||
@@ -444,7 +450,8 @@ namespace PowderCoating.Web.Controllers
|
|||||||
var trimmedName = request.Name.Trim();
|
var trimmedName = request.Name.Trim();
|
||||||
|
|
||||||
// Check for duplicate category name under the same parent (case-insensitive)
|
// Check for duplicate category name under the same parent (case-insensitive)
|
||||||
var allCategories = await _unitOfWork.CatalogCategories.GetAllAsync();
|
var quickCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allCategories = await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == quickCompanyId);
|
||||||
var existingCategory = allCategories.FirstOrDefault(c =>
|
var existingCategory = allCategories.FirstOrDefault(c =>
|
||||||
c.Name.Equals(trimmedName, StringComparison.OrdinalIgnoreCase) &&
|
c.Name.Equals(trimmedName, StringComparison.OrdinalIgnoreCase) &&
|
||||||
c.ParentCategoryId == request.ParentCategoryId);
|
c.ParentCategoryId == request.ParentCategoryId);
|
||||||
@@ -500,8 +507,9 @@ namespace PowderCoating.Web.Controllers
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var treeCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
var categories = await _unitOfWork.CatalogCategories
|
var categories = await _unitOfWork.CatalogCategories
|
||||||
.GetAllAsync(false, c => c.SubCategories, c => c.Items);
|
.FindAsync(c => c.CompanyId == treeCompanyId, false, c => c.SubCategories, c => c.Items);
|
||||||
|
|
||||||
// Build tree from root categories
|
// Build tree from root categories
|
||||||
var rootCategories = categories
|
var rootCategories = categories
|
||||||
@@ -535,7 +543,8 @@ namespace PowderCoating.Web.Controllers
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList();
|
var dropdownCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var categories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == dropdownCompanyId)).ToList();
|
||||||
|
|
||||||
// Build hierarchical list (parents before children)
|
// Build hierarchical list (parents before children)
|
||||||
var hierarchicalList = new List<CatalogCategory>();
|
var hierarchicalList = new List<CatalogCategory>();
|
||||||
@@ -573,7 +582,8 @@ namespace PowderCoating.Web.Controllers
|
|||||||
/// </param>
|
/// </param>
|
||||||
private async Task PopulateParentCategoryDropdown(int? excludeCategoryId = null)
|
private async Task PopulateParentCategoryDropdown(int? excludeCategoryId = null)
|
||||||
{
|
{
|
||||||
var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList();
|
var parentDropCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var categories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == parentDropCompanyId)).ToList();
|
||||||
|
|
||||||
// Exclude the current category and its descendants to prevent circular references
|
// Exclude the current category and its descendants to prevent circular references
|
||||||
var excludedIds = new HashSet<int>();
|
var excludedIds = new HashSet<int>();
|
||||||
@@ -700,7 +710,8 @@ namespace PowderCoating.Web.Controllers
|
|||||||
if (categoryId == newParentId)
|
if (categoryId == newParentId)
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList();
|
var circleCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var categories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == circleCompanyId)).ToList();
|
||||||
var current = categories.FirstOrDefault(c => c.Id == newParentId);
|
var current = categories.FirstOrDefault(c => c.Id == newParentId);
|
||||||
|
|
||||||
while (current != null)
|
while (current != null)
|
||||||
|
|||||||
@@ -83,7 +83,8 @@ namespace PowderCoating.Web.Controllers
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Get all categories with their items
|
// Get all categories with their items
|
||||||
var allCategories = (await _unitOfWork.CatalogCategories.GetAllAsync(false, c => c.Items)).ToList();
|
var itemsCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allCategories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == itemsCompanyId, false, c => c.Items)).ToList();
|
||||||
var allItems = allCategories.SelectMany(c => c.Items).ToList();
|
var allItems = allCategories.SelectMany(c => c.Items).ToList();
|
||||||
|
|
||||||
// Apply search filter
|
// Apply search filter
|
||||||
@@ -578,7 +579,8 @@ namespace PowderCoating.Web.Controllers
|
|||||||
return Json(new List<object>());
|
return Json(new List<object>());
|
||||||
}
|
}
|
||||||
|
|
||||||
var allItems = await _unitOfWork.CatalogItems.GetAllAsync(false, i => i.Category);
|
var searchCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allItems = await _unitOfWork.CatalogItems.FindAsync(i => i.CompanyId == searchCompanyId, false, i => i.Category);
|
||||||
var search = searchTerm.ToLower();
|
var search = searchTerm.ToLower();
|
||||||
|
|
||||||
var items = allItems
|
var items = allItems
|
||||||
@@ -694,7 +696,8 @@ namespace PowderCoating.Web.Controllers
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task PopulateCategoryDropdown()
|
private async Task PopulateCategoryDropdown()
|
||||||
{
|
{
|
||||||
var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var categories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == companyId)).ToList();
|
||||||
|
|
||||||
// Build hierarchical list (parents before children)
|
// Build hierarchical list (parents before children)
|
||||||
var hierarchicalList = new List<CatalogCategory>();
|
var hierarchicalList = new List<CatalogCategory>();
|
||||||
@@ -1045,7 +1048,7 @@ namespace PowderCoating.Web.Controllers
|
|||||||
// Load all categories so we can build full paths (e.g. "Cerakote > Firearms").
|
// Load all categories so we can build full paths (e.g. "Cerakote > Firearms").
|
||||||
// The full path gives Claude the coating-type context it needs — an item in
|
// The full path gives Claude the coating-type context it needs — an item in
|
||||||
// "Firearms" under "Cerakote" costs very differently than one under "Powder Coat".
|
// "Firearms" under "Cerakote" costs very differently than one under "Powder Coat".
|
||||||
var allCategories = (await _unitOfWork.CatalogCategories.GetAllAsync())
|
var allCategories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == currentUser.CompanyId))
|
||||||
.ToDictionary(c => c.Id);
|
.ToDictionary(c => c.Id);
|
||||||
|
|
||||||
// Load company operating costs
|
// Load company operating costs
|
||||||
|
|||||||
@@ -142,10 +142,10 @@ public class CompanySettingsController : Controller
|
|||||||
&& !connectClientId.Contains("your_connect_client_id_here", StringComparison.OrdinalIgnoreCase);
|
&& !connectClientId.Contains("your_connect_client_id_here", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
// Load notification templates for inline tab
|
// Load notification templates for inline tab
|
||||||
var existing = await _unitOfWork.NotificationTemplates.GetAllAsync();
|
var existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);
|
||||||
var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList());
|
var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList());
|
||||||
if (seeded > 0)
|
if (seeded > 0)
|
||||||
existing = await _unitOfWork.NotificationTemplates.GetAllAsync();
|
existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);
|
||||||
|
|
||||||
dto.NotificationTemplates = existing
|
dto.NotificationTemplates = existing
|
||||||
.OrderBy(t => (int)t.NotificationType).ThenBy(t => (int)t.Channel)
|
.OrderBy(t => (int)t.NotificationType).ThenBy(t => (int)t.Channel)
|
||||||
@@ -755,8 +755,8 @@ public class CompanySettingsController : Controller
|
|||||||
|
|
||||||
var costs = company.OperatingCosts;
|
var costs = company.OperatingCosts;
|
||||||
|
|
||||||
var ovens = (await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive)).OrderBy(o => o.DisplayOrder).ToList();
|
var ovens = (await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive && o.CompanyId == companyId.Value)).OrderBy(o => o.DisplayOrder).ToList();
|
||||||
var coatingCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsCoating)).ToList();
|
var coatingCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsCoating && c.CompanyId == companyId.Value)).ToList();
|
||||||
|
|
||||||
var sb = new System.Text.StringBuilder();
|
var sb = new System.Text.StringBuilder();
|
||||||
|
|
||||||
@@ -920,7 +920,8 @@ public class CompanySettingsController : Controller
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var statuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var statuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == companyId);
|
||||||
var sortedStatuses = statuses.OrderBy(s => s.DisplayOrder).ToList();
|
var sortedStatuses = statuses.OrderBy(s => s.DisplayOrder).ToList();
|
||||||
|
|
||||||
var dtos = _mapper.Map<List<JobStatusLookupDto>>(sortedStatuses);
|
var dtos = _mapper.Map<List<JobStatusLookupDto>>(sortedStatuses);
|
||||||
@@ -1071,7 +1072,8 @@ public class CompanySettingsController : Controller
|
|||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
return Json(new { success = false, message = "Invalid data" });
|
return Json(new { success = false, message = "Invalid data" });
|
||||||
|
|
||||||
var statuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||||
|
var statuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == (companyId ?? 0));
|
||||||
|
|
||||||
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
||||||
{
|
{
|
||||||
@@ -1084,7 +1086,6 @@ public class CompanySettingsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
||||||
if (companyId.HasValue) _lookupCache.InvalidateCompanyCache(companyId.Value);
|
if (companyId.HasValue) _lookupCache.InvalidateCompanyCache(companyId.Value);
|
||||||
|
|
||||||
_logger.LogInformation("Job statuses reordered");
|
_logger.LogInformation("Job statuses reordered");
|
||||||
@@ -1113,7 +1114,8 @@ public class CompanySettingsController : Controller
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var priorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId);
|
||||||
var sortedPriorities = priorities.OrderBy(p => p.DisplayOrder).ToList();
|
var sortedPriorities = priorities.OrderBy(p => p.DisplayOrder).ToList();
|
||||||
|
|
||||||
var dtos = _mapper.Map<List<JobPriorityLookupDto>>(sortedPriorities);
|
var dtos = _mapper.Map<List<JobPriorityLookupDto>>(sortedPriorities);
|
||||||
@@ -1258,7 +1260,8 @@ public class CompanySettingsController : Controller
|
|||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
return Json(new { success = false, message = "Invalid data" });
|
return Json(new { success = false, message = "Invalid data" });
|
||||||
|
|
||||||
var priorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId);
|
||||||
|
|
||||||
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
||||||
{
|
{
|
||||||
@@ -1297,7 +1300,8 @@ public class CompanySettingsController : Controller
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var statuses = await _unitOfWork.QuoteStatusLookups.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var statuses = await _unitOfWork.QuoteStatusLookups.FindAsync(s => s.CompanyId == companyId);
|
||||||
var sortedStatuses = statuses.OrderBy(s => s.DisplayOrder).ToList();
|
var sortedStatuses = statuses.OrderBy(s => s.DisplayOrder).ToList();
|
||||||
|
|
||||||
var dtos = _mapper.Map<List<QuoteStatusLookupDto>>(sortedStatuses);
|
var dtos = _mapper.Map<List<QuoteStatusLookupDto>>(sortedStatuses);
|
||||||
@@ -1478,7 +1482,8 @@ public class CompanySettingsController : Controller
|
|||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
return Json(new { success = false, message = "Invalid data" });
|
return Json(new { success = false, message = "Invalid data" });
|
||||||
|
|
||||||
var statuses = await _unitOfWork.QuoteStatusLookups.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var statuses = await _unitOfWork.QuoteStatusLookups.FindAsync(s => s.CompanyId == companyId);
|
||||||
|
|
||||||
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
||||||
{
|
{
|
||||||
@@ -1517,7 +1522,8 @@ public class CompanySettingsController : Controller
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var services = await _unitOfWork.PrepServices.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var services = await _unitOfWork.PrepServices.FindAsync(s => s.CompanyId == companyId);
|
||||||
var sortedServices = services.OrderBy(s => s.DisplayOrder).ToList();
|
var sortedServices = services.OrderBy(s => s.DisplayOrder).ToList();
|
||||||
|
|
||||||
var dtos = _mapper.Map<List<PrepServiceDto>>(sortedServices);
|
var dtos = _mapper.Map<List<PrepServiceDto>>(sortedServices);
|
||||||
@@ -1639,7 +1645,8 @@ public class CompanySettingsController : Controller
|
|||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
return Json(new { success = false, message = "Invalid data" });
|
return Json(new { success = false, message = "Invalid data" });
|
||||||
|
|
||||||
var services = await _unitOfWork.PrepServices.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var services = await _unitOfWork.PrepServices.FindAsync(s => s.CompanyId == companyId);
|
||||||
|
|
||||||
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
||||||
{
|
{
|
||||||
@@ -1812,7 +1819,8 @@ public class CompanySettingsController : Controller
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var types = await _unitOfWork.AppointmentTypeLookups.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var types = await _unitOfWork.AppointmentTypeLookups.FindAsync(t => t.CompanyId == companyId);
|
||||||
var sortedTypes = types.OrderBy(t => t.DisplayOrder).ToList();
|
var sortedTypes = types.OrderBy(t => t.DisplayOrder).ToList();
|
||||||
|
|
||||||
var dtos = _mapper.Map<List<AppointmentTypeLookupDto>>(sortedTypes);
|
var dtos = _mapper.Map<List<AppointmentTypeLookupDto>>(sortedTypes);
|
||||||
@@ -1956,7 +1964,8 @@ public class CompanySettingsController : Controller
|
|||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
return Json(new { success = false, message = "Invalid data" });
|
return Json(new { success = false, message = "Invalid data" });
|
||||||
|
|
||||||
var types = await _unitOfWork.AppointmentTypeLookups.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var types = await _unitOfWork.AppointmentTypeLookups.FindAsync(t => t.CompanyId == companyId);
|
||||||
|
|
||||||
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
||||||
{
|
{
|
||||||
@@ -1996,7 +2005,8 @@ public class CompanySettingsController : Controller
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
|
||||||
var sortedCategories = categories.OrderBy(c => c.DisplayOrder).ToList();
|
var sortedCategories = categories.OrderBy(c => c.DisplayOrder).ToList();
|
||||||
|
|
||||||
var dtos = _mapper.Map<List<InventoryCategoryLookupDto>>(sortedCategories);
|
var dtos = _mapper.Map<List<InventoryCategoryLookupDto>>(sortedCategories);
|
||||||
@@ -2132,7 +2142,8 @@ public class CompanySettingsController : Controller
|
|||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
return Json(new { success = false, message = "Invalid data" });
|
return Json(new { success = false, message = "Invalid data" });
|
||||||
|
|
||||||
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
|
||||||
|
|
||||||
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
||||||
{
|
{
|
||||||
@@ -2349,12 +2360,12 @@ public class CompanySettingsController : Controller
|
|||||||
if (companyId == null) return RedirectToAction(nameof(Index));
|
if (companyId == null) return RedirectToAction(nameof(Index));
|
||||||
|
|
||||||
// Load all existing templates for this company
|
// Load all existing templates for this company
|
||||||
var existing = await _unitOfWork.NotificationTemplates.GetAllAsync();
|
var existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);
|
||||||
|
|
||||||
// Auto-seed any missing canonical combinations
|
// Auto-seed any missing canonical combinations
|
||||||
var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList());
|
var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList());
|
||||||
if (seeded > 0)
|
if (seeded > 0)
|
||||||
existing = await _unitOfWork.NotificationTemplates.GetAllAsync();
|
existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);
|
||||||
|
|
||||||
var dtos = existing.OrderBy(t => (int)t.NotificationType).ThenBy(t => (int)t.Channel)
|
var dtos = existing.OrderBy(t => (int)t.NotificationType).ThenBy(t => (int)t.Channel)
|
||||||
.Select(t => new NotificationTemplateDto
|
.Select(t => new NotificationTemplateDto
|
||||||
|
|||||||
@@ -315,7 +315,8 @@ public class CreditMemosController : Controller
|
|||||||
|
|
||||||
private async Task PopulateCustomersAsync(int? selectedId)
|
private async Task PopulateCustomersAsync(int? selectedId)
|
||||||
{
|
{
|
||||||
var customers = await _unitOfWork.Customers.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
|
||||||
ViewBag.Customers = customers
|
ViewBag.Customers = customers
|
||||||
.OrderBy(c => c.CompanyName ?? $"{c.ContactFirstName} {c.ContactLastName}".Trim())
|
.OrderBy(c => c.CompanyName ?? $"{c.ContactFirstName} {c.ContactLastName}".Trim())
|
||||||
.Select(c => new SelectListItem
|
.Select(c => new SelectListItem
|
||||||
|
|||||||
@@ -342,14 +342,16 @@ public class DashboardController : Controller
|
|||||||
TipOfTheDay = data.TipOfTheDay
|
TipOfTheDay = data.TipOfTheDay
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Resolve company once so all remaining queries are explicitly scoped
|
||||||
|
var currentCompanyId = _tenantContext.GetCurrentCompanyId();
|
||||||
|
var companyId = currentCompanyId ?? 0;
|
||||||
|
|
||||||
// Dropdowns for the "Add Custom Powder to Inventory" modal
|
// Dropdowns for the "Add Custom Powder to Inventory" modal
|
||||||
var inventoryCategories = (await _unitOfWork.InventoryCategoryLookups.GetAllAsync())
|
var inventoryCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsActive && c.CompanyId == companyId))
|
||||||
.Where(c => c.IsActive)
|
|
||||||
.OrderBy(c => c.DisplayOrder)
|
.OrderBy(c => c.DisplayOrder)
|
||||||
.Select(c => new { c.Id, c.DisplayName })
|
.Select(c => new { c.Id, c.DisplayName })
|
||||||
.ToList();
|
.ToList();
|
||||||
var vendors = (await _unitOfWork.Vendors.GetAllAsync())
|
var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.IsActive && v.CompanyId == companyId))
|
||||||
.Where(v => v.IsActive)
|
|
||||||
.OrderBy(v => v.CompanyName)
|
.OrderBy(v => v.CompanyName)
|
||||||
.Select(v => new { v.Id, v.CompanyName })
|
.Select(v => new { v.Id, v.CompanyName })
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -357,7 +359,6 @@ public class DashboardController : Controller
|
|||||||
ViewBag.VendorList = vendors;
|
ViewBag.VendorList = vendors;
|
||||||
|
|
||||||
// Config health check — surface setup gaps to company admins
|
// Config health check — surface setup gaps to company admins
|
||||||
var currentCompanyId = _tenantContext.GetCurrentCompanyId();
|
|
||||||
if (currentCompanyId.HasValue)
|
if (currentCompanyId.HasValue)
|
||||||
{
|
{
|
||||||
ViewBag.ConfigHealth = await _configHealth.CheckAsync(currentCompanyId.Value);
|
ViewBag.ConfigHealth = await _configHealth.CheckAsync(currentCompanyId.Value);
|
||||||
@@ -711,8 +712,8 @@ public class DashboardController : Controller
|
|||||||
i => i.Coats.Any(c => c.Id == coatId), false, i => i.Job);
|
i => i.Coats.Any(c => c.Id == coatId), false, i => i.Job);
|
||||||
var companyId = jobItem?.Job?.CompanyId ?? _tenantContext.GetCurrentCompanyId() ?? 0;
|
var companyId = jobItem?.Job?.CompanyId ?? _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
// Check SKU uniqueness
|
// Check SKU uniqueness within this company
|
||||||
if (await _unitOfWork.InventoryItems.AnyAsync(i => i.SKU == sku.Trim()))
|
if (await _unitOfWork.InventoryItems.AnyAsync(i => i.SKU == sku.Trim() && i.CompanyId == companyId))
|
||||||
return Json(new { success = false, message = $"SKU '{sku}' already exists in inventory." });
|
return Json(new { success = false, message = $"SKU '{sku}' already exists in inventory." });
|
||||||
|
|
||||||
// Determine category display name for legacy field
|
// Determine category display name for legacy field
|
||||||
|
|||||||
@@ -160,7 +160,8 @@ public class InventoryController : Controller
|
|||||||
var pagedResult = PagedResult<InventoryListDto>.From(gridRequest, itemDtos, totalCount);
|
var pagedResult = PagedResult<InventoryListDto>.From(gridRequest, itemDtos, totalCount);
|
||||||
|
|
||||||
// Load all items once to compute sidebar stats and category list in memory
|
// Load all items once to compute sidebar stats and category list in memory
|
||||||
var allItems = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allItems = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
|
||||||
ViewBag.Categories = allItems.Select(i => i.Category).Where(c => c != null).Distinct().OrderBy(c => c).ToList();
|
ViewBag.Categories = allItems.Select(i => i.Category).Where(c => c != null).Distinct().OrderBy(c => c).ToList();
|
||||||
ViewBag.StatsLowStockCount = allItems.Count(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
|
ViewBag.StatsLowStockCount = allItems.Count(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
|
||||||
ViewBag.StatsActiveCount = allItems.Count(i => i.IsActive);
|
ViewBag.StatsActiveCount = allItems.Count(i => i.IsActive);
|
||||||
@@ -1106,7 +1107,8 @@ public class InventoryController : Controller
|
|||||||
|
|
||||||
// Build a set of SKUs already in this company's inventory so we can exclude them.
|
// Build a set of SKUs already in this company's inventory so we can exclude them.
|
||||||
// When editing, the current item's own SKU is re-included so its catalog entry still appears.
|
// When editing, the current item's own SKU is re-included so its catalog entry still appears.
|
||||||
var existingItems = await _unitOfWork.InventoryItems.GetAllAsync();
|
var skuCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var existingItems = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == skuCompanyId);
|
||||||
var existingSkus = existingItems
|
var existingSkus = existingItems
|
||||||
.Where(i => !string.IsNullOrWhiteSpace(i.ManufacturerPartNumber) && i.Id != (currentId ?? 0))
|
.Where(i => !string.IsNullOrWhiteSpace(i.ManufacturerPartNumber) && i.Id != (currentId ?? 0))
|
||||||
.Select(i => i.ManufacturerPartNumber!.Trim().ToLower())
|
.Select(i => i.ManufacturerPartNumber!.Trim().ToLower())
|
||||||
@@ -1182,7 +1184,7 @@ public class InventoryController : Controller
|
|||||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
// Find the default coating category to assign
|
// Find the default coating category to assign
|
||||||
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
|
var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
|
||||||
var coatingCategory = categories
|
var coatingCategory = categories
|
||||||
.Where(c => c.IsActive && c.IsCoating)
|
.Where(c => c.IsActive && c.IsCoating)
|
||||||
.OrderBy(c => c.DisplayOrder)
|
.OrderBy(c => c.DisplayOrder)
|
||||||
@@ -1369,11 +1371,11 @@ public class InventoryController : Controller
|
|||||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
ViewBag.AiInventoryAssistEnabled = await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId);
|
ViewBag.AiInventoryAssistEnabled = await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId);
|
||||||
|
|
||||||
var vendors = await _unitOfWork.Vendors.GetAllAsync();
|
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId);
|
||||||
ViewBag.Vendors = new SelectList(vendors.Where(s => s.IsActive).OrderBy(s => s.CompanyName), "Id", "CompanyName");
|
ViewBag.Vendors = new SelectList(vendors.Where(s => s.IsActive).OrderBy(s => s.CompanyName), "Id", "CompanyName");
|
||||||
|
|
||||||
// Load categories from lookup table
|
// Load categories from lookup table
|
||||||
var allCategories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
|
var allCategories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
|
||||||
var categories = allCategories
|
var categories = allCategories
|
||||||
.Where(c => c.IsActive)
|
.Where(c => c.IsActive)
|
||||||
.OrderBy(c => c.DisplayOrder)
|
.OrderBy(c => c.DisplayOrder)
|
||||||
@@ -1738,7 +1740,8 @@ public class InventoryController : Controller
|
|||||||
DateTime? dateTo,
|
DateTime? dateTo,
|
||||||
string? typeFilter)
|
string? typeFilter)
|
||||||
{
|
{
|
||||||
var allItems = await _unitOfWork.InventoryItems.GetAllAsync();
|
var ledgerCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allItems = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == ledgerCompanyId);
|
||||||
var itemList = allItems
|
var itemList = allItems
|
||||||
.Where(i => i.IsActive || i.QuantityOnHand > 0)
|
.Where(i => i.IsActive || i.QuantityOnHand > 0)
|
||||||
.OrderBy(i => i.Name)
|
.OrderBy(i => i.Name)
|
||||||
|
|||||||
@@ -340,13 +340,14 @@ public class InvoicesController : Controller
|
|||||||
var costs = await _unitOfWork.CompanyOperatingCosts
|
var costs = await _unitOfWork.CompanyOperatingCosts
|
||||||
.FirstOrDefaultAsync(c => c.CompanyId == currentUser.CompanyId && !c.IsDeleted);
|
.FirstOrDefaultAsync(c => c.CompanyId == currentUser.CompanyId && !c.IsDeleted);
|
||||||
|
|
||||||
|
var defaultTerms = prefs?.DefaultPaymentTerms ?? "Net 30";
|
||||||
var dto = new CreateInvoiceDto
|
var dto = new CreateInvoiceDto
|
||||||
{
|
{
|
||||||
PreparedById = currentUser.Id,
|
PreparedById = currentUser.Id,
|
||||||
InvoiceDate = DateTime.Today,
|
InvoiceDate = DateTime.Today,
|
||||||
DueDate = DateTime.Today.AddDays(prefs?.DefaultTurnaroundDays ?? 30),
|
DueDate = PaymentTermsParser.CalculateDueDate(defaultTerms, DateTime.Today),
|
||||||
TaxPercent = costs?.TaxPercent ?? 0,
|
TaxPercent = costs?.TaxPercent ?? 0,
|
||||||
Terms = prefs?.DefaultPaymentTerms ?? "Net 30"
|
Terms = defaultTerms
|
||||||
};
|
};
|
||||||
|
|
||||||
if (jobId.HasValue)
|
if (jobId.HasValue)
|
||||||
@@ -378,6 +379,13 @@ public class InvoicesController : Controller
|
|||||||
var defaultRevenueAccount = await _unitOfWork.Accounts
|
var defaultRevenueAccount = await _unitOfWork.Accounts
|
||||||
.FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.IsActive);
|
.FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.IsActive);
|
||||||
|
|
||||||
|
// Deserialize the job's pricing snapshot up front — it is authoritative for discount,
|
||||||
|
// tax, and fees for both quote-based and direct jobs, because it is recalculated on
|
||||||
|
// every save and reflects any edits made after quote conversion.
|
||||||
|
QuotePricingBreakdownDto? jobBreakdown = null;
|
||||||
|
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
|
||||||
|
jobBreakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
|
||||||
|
|
||||||
// If the job came from a quote, load it so we can use the agreed pricing.
|
// If the job came from a quote, load it so we can use the agreed pricing.
|
||||||
// The quote stores the approved total including oven batch cost and shop supplies —
|
// The quote stores the approved total including oven batch cost and shop supplies —
|
||||||
// these are quote-level charges that are NOT stored on individual job items.
|
// these are quote-level charges that are NOT stored on individual job items.
|
||||||
@@ -461,17 +469,15 @@ public class InvoicesController : Controller
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the quote's agreed tax rate and discount — not current company defaults
|
// Use the quote's agreed tax rate and discount — these represent the customer-approved
|
||||||
dto.TaxPercent = sourceQuote.TaxPercent;
|
// price and must not be recomputed from the job's current state.
|
||||||
|
dto.TaxPercent = sourceQuote.TaxPercent;
|
||||||
dto.DiscountAmount = sourceQuote.DiscountAmount;
|
dto.DiscountAmount = sourceQuote.DiscountAmount;
|
||||||
}
|
}
|
||||||
else if (hadJobItems)
|
else if (hadJobItems)
|
||||||
{
|
{
|
||||||
// Direct job — no source quote. Read all charges from the pricing snapshot so the
|
// Direct job — no source quote. Read all charges from the pricing snapshot so the
|
||||||
// invoice always matches the total shown on the job's Pricing Summary card.
|
// invoice always matches the total shown on the job's Pricing Summary card.
|
||||||
QuotePricingBreakdownDto? jobBreakdown = null;
|
|
||||||
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
|
|
||||||
jobBreakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
|
|
||||||
|
|
||||||
if (job.OvenBatchCost > 0.01m)
|
if (job.OvenBatchCost > 0.01m)
|
||||||
{
|
{
|
||||||
@@ -529,6 +535,22 @@ public class InvoicesController : Controller
|
|||||||
RevenueAccountId = defaultRevenueAccount?.Id
|
RevenueAccountId = defaultRevenueAccount?.Id
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dto.DiscountAmount = jobBreakdown?.DiscountAmount ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inherit payment terms from the source quote or the customer — more specific than
|
||||||
|
// the company-wide default set in the outer DTO. Quote terms take priority because
|
||||||
|
// they represent the agreed price; customer terms are next best for direct jobs.
|
||||||
|
var inheritedTerms = sourceQuote?.Terms ?? job.Customer?.PaymentTerms;
|
||||||
|
if (!string.IsNullOrWhiteSpace(inheritedTerms))
|
||||||
|
{
|
||||||
|
dto.Terms = inheritedTerms;
|
||||||
|
dto.DueDate = PaymentTermsParser.CalculateDueDate(inheritedTerms, DateTime.Today)
|
||||||
|
?? dto.DueDate;
|
||||||
|
var (discPct, discDays) = PaymentTermsParser.ParseEarlyPaymentDiscount(inheritedTerms);
|
||||||
|
dto.EarlyPaymentDiscountPercent = discPct;
|
||||||
|
dto.EarlyPaymentDiscountDays = discDays;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override tax to 0 for tax-exempt customers, regardless of company default or quote rate
|
// Override tax to 0 for tax-exempt customers, regardless of company default or quote rate
|
||||||
@@ -2191,7 +2213,7 @@ public class InvoicesController : Controller
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task PopulateCreateViewBagAsync(int companyId, string? selectedTerms = null)
|
private async Task PopulateCreateViewBagAsync(int companyId, string? selectedTerms = null)
|
||||||
{
|
{
|
||||||
var customers = await _unitOfWork.Customers.GetAllAsync();
|
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
|
||||||
ViewBag.Customers = customers.Where(c => c.IsActive).OrderBy(c => c.CompanyName ?? c.ContactLastName).ToList();
|
ViewBag.Customers = customers.Where(c => c.IsActive).OrderBy(c => c.CompanyName ?? c.ContactLastName).ToList();
|
||||||
|
|
||||||
// Expose company default tax rate and exempt customer IDs for client-side tax handling
|
// Expose company default tax rate and exempt customer IDs for client-side tax handling
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ public class JobTemplatesController : Controller
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<IActionResult> Index()
|
public async Task<IActionResult> Index()
|
||||||
{
|
{
|
||||||
var templates = await _unitOfWork.JobTemplates.GetAllAsync(
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var templates = await _unitOfWork.JobTemplates.FindAsync(
|
||||||
|
t => t.CompanyId == companyId,
|
||||||
false,
|
false,
|
||||||
t => t.Customer,
|
t => t.Customer,
|
||||||
t => t.Items);
|
t => t.Items);
|
||||||
|
|||||||
@@ -498,6 +498,23 @@ public class JobsController : Controller
|
|||||||
.OrderByDescending(t => t.TransactionDate).ToList();
|
.OrderByDescending(t => t.TransactionDate).ToList();
|
||||||
ViewBag.MaterialsUsed = allJobTransactions;
|
ViewBag.MaterialsUsed = allJobTransactions;
|
||||||
|
|
||||||
|
// Inventory items for the manual log-material modal
|
||||||
|
var inventoryItemsForModal = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == job.CompanyId))
|
||||||
|
.OrderBy(i => i.Name)
|
||||||
|
.Select(i => new { i.Id, i.Name, i.Manufacturer, i.UnitOfMeasure, i.QuantityOnHand })
|
||||||
|
.ToList();
|
||||||
|
var jsonOpts = new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase };
|
||||||
|
ViewBag.InventoryItemsForModal = System.Text.Json.JsonSerializer.Serialize(inventoryItemsForModal, jsonOpts);
|
||||||
|
|
||||||
|
// IDs of powders already assigned to this job's coats — shown at top of log-material dropdown
|
||||||
|
var jobPowderIds = (jobDto.Items ?? new List<PowderCoating.Application.DTOs.Job.JobItemDto>())
|
||||||
|
.SelectMany(i => i.Coats ?? new List<PowderCoating.Application.DTOs.Job.JobItemCoatDto>())
|
||||||
|
.Where(c => c.InventoryItemId.HasValue)
|
||||||
|
.Select(c => c.InventoryItemId!.Value)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
ViewBag.JobPowderIds = System.Text.Json.JsonSerializer.Serialize(jobPowderIds, jsonOpts);
|
||||||
|
|
||||||
// Pre-logged powder grouped by InventoryItemId (for Complete Job modal pre-fill)
|
// Pre-logged powder grouped by InventoryItemId (for Complete Job modal pre-fill)
|
||||||
ViewBag.PreLoggedPowder = allJobTransactions
|
ViewBag.PreLoggedPowder = allJobTransactions
|
||||||
.GroupBy(t => t.InventoryItemId)
|
.GroupBy(t => t.InventoryItemId)
|
||||||
@@ -511,7 +528,7 @@ public class JobsController : Controller
|
|||||||
ViewBag.JobPhotoMax = photoMax;
|
ViewBag.JobPhotoMax = photoMax;
|
||||||
|
|
||||||
// Customer list for inline customer-change dropdown
|
// Customer list for inline customer-change dropdown
|
||||||
var allCustomers = await _unitOfWork.Customers.GetAllAsync();
|
var allCustomers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == job.CompanyId);
|
||||||
ViewBag.CustomerSelectList = allCustomers
|
ViewBag.CustomerSelectList = allCustomers
|
||||||
.Where(c => c.IsActive)
|
.Where(c => c.IsActive)
|
||||||
.Select(c => new SelectListItem
|
.Select(c => new SelectListItem
|
||||||
@@ -617,7 +634,8 @@ public class JobsController : Controller
|
|||||||
|
|
||||||
if (job == null) return NotFound();
|
if (job == null) return NotFound();
|
||||||
|
|
||||||
var allStatuses = (await _unitOfWork.JobStatusLookups.GetAllAsync())
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allStatuses = (await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == companyId))
|
||||||
.OrderBy(s => s.DisplayOrder).ToList();
|
.OrderBy(s => s.DisplayOrder).ToList();
|
||||||
|
|
||||||
ViewBag.AllStatuses = allStatuses;
|
ViewBag.AllStatuses = allStatuses;
|
||||||
@@ -640,7 +658,7 @@ public class JobsController : Controller
|
|||||||
|
|
||||||
if (job == null) return NotFound();
|
if (job == null) return NotFound();
|
||||||
|
|
||||||
var allStatuses = (await _unitOfWork.JobStatusLookups.GetAllAsync()).ToList();
|
var allStatuses = (await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == job.CompanyId)).ToList();
|
||||||
var newStatus = allStatuses.FirstOrDefault(s => s.Id == newStatusId);
|
var newStatus = allStatuses.FirstOrDefault(s => s.Id == newStatusId);
|
||||||
if (newStatus == null) return BadRequest("Invalid status.");
|
if (newStatus == null) return BadRequest("Invalid status.");
|
||||||
|
|
||||||
@@ -828,7 +846,7 @@ public class JobsController : Controller
|
|||||||
// Optionally advance status to In Preparation
|
// Optionally advance status to In Preparation
|
||||||
if (advanceToInPreparation && jobToUpdate.JobStatus.StatusCode != AppConstants.StatusCodes.Job.InPreparation)
|
if (advanceToInPreparation && jobToUpdate.JobStatus.StatusCode != AppConstants.StatusCodes.Job.InPreparation)
|
||||||
{
|
{
|
||||||
var allStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
|
var allStatuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == jobToUpdate.CompanyId);
|
||||||
var inPrepStatus = allStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.InPreparation);
|
var inPrepStatus = allStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.InPreparation);
|
||||||
if (inPrepStatus != null)
|
if (inPrepStatus != null)
|
||||||
{
|
{
|
||||||
@@ -885,7 +903,7 @@ public class JobsController : Controller
|
|||||||
|
|
||||||
if (advanceToInPreparation && job.JobStatus.StatusCode != AppConstants.StatusCodes.Job.InPreparation && !job.JobStatus.IsTerminalStatus)
|
if (advanceToInPreparation && job.JobStatus.StatusCode != AppConstants.StatusCodes.Job.InPreparation && !job.JobStatus.IsTerminalStatus)
|
||||||
{
|
{
|
||||||
var allStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
|
var allStatuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == job.CompanyId);
|
||||||
var inPrepStatus = allStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.InPreparation);
|
var inPrepStatus = allStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.InPreparation);
|
||||||
if (inPrepStatus != null)
|
if (inPrepStatus != null)
|
||||||
{
|
{
|
||||||
@@ -1060,6 +1078,8 @@ public class JobsController : Controller
|
|||||||
QuoteId = dto.QuoteId,
|
QuoteId = dto.QuoteId,
|
||||||
AssignedUserId = dto.AssignedUserId,
|
AssignedUserId = dto.AssignedUserId,
|
||||||
OvenCostId = dto.OvenCostId,
|
OvenCostId = dto.OvenCostId,
|
||||||
|
OvenBatches = dto.OvenBatches > 0 ? dto.OvenBatches : 1,
|
||||||
|
OvenCycleMinutes = dto.OvenCycleMinutes,
|
||||||
Description = dto.Description,
|
Description = dto.Description,
|
||||||
JobPriorityId = dto.JobPriorityId,
|
JobPriorityId = dto.JobPriorityId,
|
||||||
JobStatusId = pendingStatus?.Id ?? 1,
|
JobStatusId = pendingStatus?.Id ?? 1,
|
||||||
@@ -1131,7 +1151,7 @@ public class JobsController : Controller
|
|||||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||||
dto.JobItems, companyId, dto.CustomerId,
|
dto.JobItems, companyId, dto.CustomerId,
|
||||||
await GetEffectiveTaxPercentAsync(dto.CustomerId, createCosts?.TaxPercent ?? 0m),
|
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.FinalPrice = totals.Total;
|
||||||
job.OvenBatchCost = totals.OvenBatchCost;
|
job.OvenBatchCost = totals.OvenBatchCost;
|
||||||
@@ -1199,6 +1219,9 @@ public class JobsController : Controller
|
|||||||
CustomerId = job.CustomerId,
|
CustomerId = job.CustomerId,
|
||||||
QuoteId = job.QuoteId,
|
QuoteId = job.QuoteId,
|
||||||
AssignedUserId = job.AssignedUserId,
|
AssignedUserId = job.AssignedUserId,
|
||||||
|
OvenCostId = job.OvenCostId,
|
||||||
|
OvenBatches = job.OvenBatches > 0 ? job.OvenBatches : 1,
|
||||||
|
OvenCycleMinutes = job.OvenCycleMinutes,
|
||||||
Description = job.Description,
|
Description = job.Description,
|
||||||
JobStatusId = job.JobStatusId,
|
JobStatusId = job.JobStatusId,
|
||||||
JobPriorityId = job.JobPriorityId,
|
JobPriorityId = job.JobPriorityId,
|
||||||
@@ -1243,6 +1266,7 @@ public class JobsController : Controller
|
|||||||
TransferEfficiency = c.TransferEfficiency,
|
TransferEfficiency = c.TransferEfficiency,
|
||||||
PowderCostPerLb = c.PowderCostPerLb,
|
PowderCostPerLb = c.PowderCostPerLb,
|
||||||
PowderToOrder = c.PowderToOrder,
|
PowderToOrder = c.PowderToOrder,
|
||||||
|
NoExtraLayerCharge = c.NoExtraLayerCharge,
|
||||||
Notes = c.Notes
|
Notes = c.Notes
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto
|
PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto
|
||||||
@@ -1373,6 +1397,9 @@ public class JobsController : Controller
|
|||||||
job.CustomerId = dto.CustomerId;
|
job.CustomerId = dto.CustomerId;
|
||||||
job.QuoteId = dto.QuoteId;
|
job.QuoteId = dto.QuoteId;
|
||||||
job.Description = dto.Description;
|
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);
|
await RecordStatusChangeAsync(job, dto.JobStatusId);
|
||||||
job.JobStatusId = dto.JobStatusId;
|
job.JobStatusId = dto.JobStatusId;
|
||||||
job.JobPriorityId = dto.JobPriorityId;
|
job.JobPriorityId = dto.JobPriorityId;
|
||||||
@@ -1599,7 +1626,7 @@ public class JobsController : Controller
|
|||||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||||
dto.JobItems, companyId, dto.CustomerId,
|
dto.JobItems, companyId, dto.CustomerId,
|
||||||
await GetEffectiveTaxPercentAsync(dto.CustomerId, editCosts?.TaxPercent ?? 0m),
|
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.FinalPrice = totals.Total;
|
||||||
job.OvenBatchCost = totals.OvenBatchCost;
|
job.OvenBatchCost = totals.OvenBatchCost;
|
||||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||||
@@ -1792,7 +1819,7 @@ public class JobsController : Controller
|
|||||||
ViewBag.AiPhotoQuotesEnabled = await _subscriptionService.CanUseAiPhotoQuoteAsync(companyId);
|
ViewBag.AiPhotoQuotesEnabled = await _subscriptionService.CanUseAiPhotoQuoteAsync(companyId);
|
||||||
|
|
||||||
await PopulateDropdowns();
|
await PopulateDropdowns();
|
||||||
await PopulatePrepServicesAsync();
|
await PopulatePrepServicesAsync(companyId);
|
||||||
var costs = await _pricingService.GetOperatingCostsAsync(companyId);
|
var costs = await _pricingService.GetOperatingCostsAsync(companyId);
|
||||||
await PopulateJobItemDropDownsAsync(companyId, costs?.OvenOperatingCostPerHour ?? 45m);
|
await PopulateJobItemDropDownsAsync(companyId, costs?.OvenOperatingCostPerHour ?? 45m);
|
||||||
ViewBag.TaxPercent = costs?.TaxPercent ?? 0m;
|
ViewBag.TaxPercent = costs?.TaxPercent ?? 0m;
|
||||||
@@ -1800,6 +1827,7 @@ public class JobsController : Controller
|
|||||||
ViewBag.ComplexityModeratePercent = costs?.ComplexityModeratePercent ?? 5m;
|
ViewBag.ComplexityModeratePercent = costs?.ComplexityModeratePercent ?? 5m;
|
||||||
ViewBag.ComplexityComplexPercent = costs?.ComplexityComplexPercent ?? 15m;
|
ViewBag.ComplexityComplexPercent = costs?.ComplexityComplexPercent ?? 15m;
|
||||||
ViewBag.ComplexityExtremePercent = costs?.ComplexityExtremePercent ?? 25m;
|
ViewBag.ComplexityExtremePercent = costs?.ComplexityExtremePercent ?? 25m;
|
||||||
|
ViewBag.DefaultOvenCycleMinutes = costs?.DefaultOvenCycleMinutes ?? 45;
|
||||||
var useMetric = await _tenantContext.UseMetricSystemAsync();
|
var useMetric = await _tenantContext.UseMetricSystemAsync();
|
||||||
ViewBag.UseMetric = useMetric;
|
ViewBag.UseMetric = useMetric;
|
||||||
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
|
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
|
||||||
@@ -1812,7 +1840,9 @@ public class JobsController : Controller
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task PopulateDropdowns()
|
private async Task PopulateDropdowns()
|
||||||
{
|
{
|
||||||
var customers = await _unitOfWork.Customers.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
|
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
|
||||||
ViewBag.Customers = new SelectList(
|
ViewBag.Customers = new SelectList(
|
||||||
customers.Where(c => c.IsActive).Select(c => new
|
customers.Where(c => c.IsActive).Select(c => new
|
||||||
{
|
{
|
||||||
@@ -1823,8 +1853,6 @@ public class JobsController : Controller
|
|||||||
}).OrderBy(c => c.DisplayName),
|
}).OrderBy(c => c.DisplayName),
|
||||||
"Id",
|
"Id",
|
||||||
"DisplayName");
|
"DisplayName");
|
||||||
|
|
||||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
|
||||||
var users = await _userManager.Users
|
var users = await _userManager.Users
|
||||||
.Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null)
|
.Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null)
|
||||||
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
|
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
|
||||||
@@ -2206,13 +2234,13 @@ public class JobsController : Controller
|
|||||||
/// Loads all active prep services into ViewBag for the item wizard's prep services step.
|
/// Loads all active prep services into ViewBag for the item wizard's prep services step.
|
||||||
/// Prep services are ordered by DisplayOrder so they appear in the intended workflow sequence.
|
/// Prep services are ordered by DisplayOrder so they appear in the intended workflow sequence.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task PopulatePrepServicesAsync()
|
private async Task PopulatePrepServicesAsync(int companyId)
|
||||||
{
|
{
|
||||||
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive);
|
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive && ps.CompanyId == companyId);
|
||||||
ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList();
|
ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList();
|
||||||
_logger.LogInformation("Populated {Count} active prep services", prepServices.Count());
|
_logger.LogInformation("Populated {Count} active prep services", prepServices.Count());
|
||||||
|
|
||||||
var blastSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive);
|
var blastSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive && b.CompanyId == companyId);
|
||||||
ViewBag.BlastSetups = blastSetups.OrderBy(b => b.DisplayOrder)
|
ViewBag.BlastSetups = blastSetups.OrderBy(b => b.DisplayOrder)
|
||||||
.Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault })
|
.Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault })
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -2648,78 +2676,80 @@ public class JobsController : Controller
|
|||||||
.GroupBy(t => t.InventoryItemId)
|
.GroupBy(t => t.InventoryItemId)
|
||||||
.ToDictionary(g => g.Key, g => Math.Abs(g.Sum(t => t.Quantity)));
|
.ToDictionary(g => g.Key, g => Math.Abs(g.Sum(t => t.Quantity)));
|
||||||
|
|
||||||
// Update actual powder usage for each coat
|
// Process powder usage submitted per inventory item (color) for the whole job.
|
||||||
foreach (var coatUsage in dto.CoatUsages)
|
// Distribute entered lbs across coats sharing that InventoryItemId proportionally
|
||||||
|
// by estimated PowderToOrder so per-coat reporting stays meaningful.
|
||||||
|
// One inventory deduction per powder (net of pre-logged credit).
|
||||||
|
if (dto.PowderUsages.Any())
|
||||||
{
|
{
|
||||||
var jobItemCoat = await _unitOfWork.JobItemCoats.GetByIdAsync(
|
// Load all coats for the job with their inventory items
|
||||||
coatUsage.JobItemCoatId,
|
var allCoats = (await _unitOfWork.JobItemCoats.FindAsync(
|
||||||
false,
|
jic => jic.JobItem != null && jic.JobItem.JobId == dto.JobId,
|
||||||
jic => jic.InventoryItem);
|
false, jic => jic.InventoryItem, jic => jic.JobItem))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
if (jobItemCoat != null)
|
foreach (var powderUsage in dto.PowderUsages)
|
||||||
{
|
{
|
||||||
jobItemCoat.ActualPowderUsedLbs = coatUsage.ActualPowderUsedLbs;
|
if (!powderUsage.ActualPowderUsedLbs.HasValue || powderUsage.ActualPowderUsedLbs.Value <= 0)
|
||||||
await _unitOfWork.JobItemCoats.UpdateAsync(jobItemCoat);
|
continue;
|
||||||
|
|
||||||
_logger.LogInformation("Updated JobItemCoat {CoatId} with {Lbs} lbs actual powder used",
|
var invItemId = powderUsage.InventoryItemId;
|
||||||
coatUsage.JobItemCoatId, coatUsage.ActualPowderUsedLbs);
|
var totalActualLbs = powderUsage.ActualPowderUsedLbs.Value;
|
||||||
|
|
||||||
// Deduct powder from inventory if using stock powder
|
// Distribute across coats using this powder proportionally by estimated lbs
|
||||||
if (jobItemCoat.InventoryItemId.HasValue &&
|
var coatsForPowder = allCoats.Where(c => c.InventoryItemId == invItemId).ToList();
|
||||||
coatUsage.ActualPowderUsedLbs.HasValue &&
|
if (coatsForPowder.Any())
|
||||||
coatUsage.ActualPowderUsedLbs.Value > 0)
|
|
||||||
{
|
{
|
||||||
var invItemId = jobItemCoat.InventoryItemId.Value;
|
var totalEstimated = coatsForPowder.Sum(c => c.PowderToOrder ?? 0m);
|
||||||
var actualLbs = coatUsage.ActualPowderUsedLbs.Value;
|
foreach (var coat in coatsForPowder)
|
||||||
|
|
||||||
// Apply available pre-logged credit so we don't double-deduct
|
|
||||||
var credit = preLoggedCredit.GetValueOrDefault(invItemId, 0m);
|
|
||||||
var deductNow = Math.Max(0m, actualLbs - credit);
|
|
||||||
// Consume credit (other coats sharing the same powder get whatever remains)
|
|
||||||
preLoggedCredit[invItemId] = Math.Max(0m, credit - actualLbs);
|
|
||||||
|
|
||||||
if (deductNow > 0)
|
|
||||||
{
|
{
|
||||||
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(invItemId);
|
var share = totalEstimated > 0
|
||||||
if (inventoryItem != null)
|
? totalActualLbs * ((coat.PowderToOrder ?? 0m) / totalEstimated)
|
||||||
{
|
: totalActualLbs / coatsForPowder.Count;
|
||||||
var transaction = new InventoryTransaction
|
coat.ActualPowderUsedLbs = Math.Round(share, 4);
|
||||||
{
|
await _unitOfWork.JobItemCoats.UpdateAsync(coat);
|
||||||
InventoryItemId = inventoryItem.Id,
|
|
||||||
TransactionType = InventoryTransactionType.JobUsage,
|
|
||||||
Quantity = -deductNow,
|
|
||||||
UnitCost = inventoryItem.UnitCost,
|
|
||||||
TotalCost = inventoryItem.UnitCost * deductNow,
|
|
||||||
TransactionDate = DateTime.UtcNow,
|
|
||||||
JobId = job.Id,
|
|
||||||
Reference = job.JobNumber,
|
|
||||||
Notes = $"Powder used for Job {job.JobNumber} - {jobItemCoat.CoatName} ({jobItemCoat.ColorName ?? "N/A"}) by {currentUser!.FirstName} {currentUser.LastName}",
|
|
||||||
BalanceAfter = inventoryItem.QuantityOnHand - deductNow,
|
|
||||||
CompanyId = job.CompanyId
|
|
||||||
};
|
|
||||||
|
|
||||||
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
|
|
||||||
inventoryItem.QuantityOnHand -= deductNow;
|
|
||||||
await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem);
|
|
||||||
|
|
||||||
// GL: DR COGS, CR Inventory Asset (accrual) — no-op if accounts not configured
|
|
||||||
if (inventoryItem.CogsAccountId.HasValue && inventoryItem.InventoryAccountId.HasValue)
|
|
||||||
{
|
|
||||||
var cost = deductNow * (inventoryItem.AverageCost > 0 ? inventoryItem.AverageCost : inventoryItem.UnitCost);
|
|
||||||
await _accountBalanceService.DebitAsync(inventoryItem.CogsAccountId, cost);
|
|
||||||
await _accountBalanceService.CreditAsync(inventoryItem.InventoryAccountId, cost);
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Deducted {Lbs} lbs (net of pre-logged) of {Item} from inventory for Job {JobNumber}. New quantity: {NewQty}",
|
|
||||||
deductNow, inventoryItem.Name, job.JobNumber, inventoryItem.QuantityOnHand);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
}
|
||||||
|
|
||||||
|
// Single inventory deduction for the whole powder, net of pre-logged credit
|
||||||
|
var credit = preLoggedCredit.GetValueOrDefault(invItemId, 0m);
|
||||||
|
var deductNow = Math.Max(0m, totalActualLbs - credit);
|
||||||
|
preLoggedCredit[invItemId] = 0m;
|
||||||
|
|
||||||
|
if (deductNow > 0)
|
||||||
|
{
|
||||||
|
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(invItemId);
|
||||||
|
if (inventoryItem != null)
|
||||||
{
|
{
|
||||||
|
inventoryItem.QuantityOnHand -= deductNow;
|
||||||
|
await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem);
|
||||||
|
|
||||||
|
var transaction = new InventoryTransaction
|
||||||
|
{
|
||||||
|
InventoryItemId = inventoryItem.Id,
|
||||||
|
TransactionType = InventoryTransactionType.JobUsage,
|
||||||
|
Quantity = -deductNow,
|
||||||
|
UnitCost = inventoryItem.UnitCost,
|
||||||
|
TotalCost = inventoryItem.UnitCost * deductNow,
|
||||||
|
TransactionDate = DateTime.UtcNow,
|
||||||
|
JobId = job.Id,
|
||||||
|
Reference = job.JobNumber,
|
||||||
|
Notes = $"Powder used for Job {job.JobNumber} by {currentUser!.FirstName} {currentUser.LastName}",
|
||||||
|
BalanceAfter = inventoryItem.QuantityOnHand,
|
||||||
|
CompanyId = job.CompanyId
|
||||||
|
};
|
||||||
|
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
|
||||||
|
|
||||||
|
if (inventoryItem.CogsAccountId.HasValue && inventoryItem.InventoryAccountId.HasValue)
|
||||||
|
{
|
||||||
|
var cost = deductNow * (inventoryItem.AverageCost > 0 ? inventoryItem.AverageCost : inventoryItem.UnitCost);
|
||||||
|
await _accountBalanceService.DebitAsync(inventoryItem.CogsAccountId, cost);
|
||||||
|
await _accountBalanceService.CreditAsync(inventoryItem.InventoryAccountId, cost);
|
||||||
|
}
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Skipped inventory deduction for JobItemCoat {CoatId} — {Lbs} lbs already pre-logged for inventory item {InvItemId}",
|
"Deducted {Lbs} lbs (net of pre-logged) of {Item} from inventory for Job {JobNumber}. New quantity: {NewQty}",
|
||||||
coatUsage.JobItemCoatId, actualLbs, invItemId);
|
deductNow, inventoryItem.Name, job.JobNumber, inventoryItem.QuantityOnHand);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3147,7 +3177,7 @@ public class JobsController : Controller
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task PopulateJobItemDropDownsAsync(int companyId, decimal fallbackOvenRate)
|
private async Task PopulateJobItemDropDownsAsync(int companyId, decimal fallbackOvenRate)
|
||||||
{
|
{
|
||||||
var inventory = await _unitOfWork.InventoryItems.GetAllAsync(false, i => i.InventoryCategory);
|
var inventory = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId, false, i => i.InventoryCategory);
|
||||||
ViewBag.InventoryCoatings = inventory
|
ViewBag.InventoryCoatings = inventory
|
||||||
.Where(i => i.IsActive && i.InventoryCategory?.IsActive == true && i.InventoryCategory.IsCoating)
|
.Where(i => i.IsActive && i.InventoryCategory?.IsActive == true && i.InventoryCategory.IsCoating)
|
||||||
.OrderBy(i => i.IsIncoming ? 1 : 0).ThenBy(i => i.InventoryCategory!.DisplayOrder).ThenBy(i => i.ColorName ?? i.Name)
|
.OrderBy(i => i.IsIncoming ? 1 : 0).ThenBy(i => i.InventoryCategory!.DisplayOrder).ThenBy(i => i.ColorName ?? i.Name)
|
||||||
@@ -3167,12 +3197,12 @@ public class JobsController : Controller
|
|||||||
isIncoming = i.IsIncoming
|
isIncoming = i.IsIncoming
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
var vendors = await _unitOfWork.Vendors.GetAllAsync(false);
|
var vendors = await _unitOfWork.Vendors.FindAsync(s => s.CompanyId == companyId, false);
|
||||||
ViewBag.Vendors = vendors
|
ViewBag.Vendors = vendors
|
||||||
.Where(s => s.IsActive).OrderBy(s => s.CompanyName)
|
.Where(s => s.IsActive).OrderBy(s => s.CompanyName)
|
||||||
.Select(s => new { value = s.Id.ToString(), text = s.CompanyName }).ToList();
|
.Select(s => new { value = s.Id.ToString(), text = s.CompanyName }).ToList();
|
||||||
|
|
||||||
var catalogItems = await _unitOfWork.CatalogItems.GetAllAsync(false, i => i.Category, i => i.Category.ParentCategory);
|
var catalogItems = await _unitOfWork.CatalogItems.FindAsync(i => i.CompanyId == companyId, false, i => i.Category, i => i.Category.ParentCategory);
|
||||||
ViewBag.CatalogItems = catalogItems
|
ViewBag.CatalogItems = catalogItems
|
||||||
.Where(i => i.IsActive)
|
.Where(i => i.IsActive)
|
||||||
.OrderBy(i => i.Category.DisplayOrder).ThenBy(i => i.DisplayOrder)
|
.OrderBy(i => i.Category.DisplayOrder).ThenBy(i => i.DisplayOrder)
|
||||||
@@ -3201,10 +3231,10 @@ public class JobsController : Controller
|
|||||||
description = i.Description
|
description = i.Description
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive);
|
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive && ps.CompanyId == companyId);
|
||||||
ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList();
|
ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList();
|
||||||
|
|
||||||
var blastSetupsForEditItems = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive);
|
var blastSetupsForEditItems = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive && b.CompanyId == companyId);
|
||||||
ViewBag.BlastSetups = blastSetupsForEditItems.OrderBy(b => b.DisplayOrder)
|
ViewBag.BlastSetups = blastSetupsForEditItems.OrderBy(b => b.DisplayOrder)
|
||||||
.Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault })
|
.Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault })
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -4080,9 +4110,87 @@ public class JobsController : Controller
|
|||||||
|
|
||||||
_logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId);
|
_logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Logs manual material usage from the job details page. Mirrors the QR scan LogUsage
|
||||||
|
/// flow in InventoryController but returns JSON so the modal can close and refresh inline.
|
||||||
|
/// Quantity is always the amount USED (caller converts from remaining if needed).
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> LogMaterial([FromBody] LogMaterialRequest req)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (req.QuantityUsed <= 0)
|
||||||
|
return Json(new { success = false, message = "Quantity used must be greater than zero." });
|
||||||
|
|
||||||
|
var item = await _unitOfWork.InventoryItems.GetByIdAsync(req.InventoryItemId);
|
||||||
|
if (item == null) return Json(new { success = false, message = "Inventory item not found." });
|
||||||
|
|
||||||
|
var job = await _unitOfWork.Jobs.GetByIdAsync(req.JobId);
|
||||||
|
if (job == null) return Json(new { success = false, message = "Job not found." });
|
||||||
|
|
||||||
|
var txnType = req.TransactionType == "Waste"
|
||||||
|
? InventoryTransactionType.Waste
|
||||||
|
: InventoryTransactionType.JobUsage;
|
||||||
|
|
||||||
|
item.QuantityOnHand -= req.QuantityUsed;
|
||||||
|
item.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _unitOfWork.InventoryItems.UpdateAsync(item);
|
||||||
|
|
||||||
|
var txn = new PowderCoating.Core.Entities.InventoryTransaction
|
||||||
|
{
|
||||||
|
InventoryItemId = item.Id,
|
||||||
|
TransactionType = txnType,
|
||||||
|
Quantity = -req.QuantityUsed,
|
||||||
|
UnitCost = item.UnitCost,
|
||||||
|
TotalCost = req.QuantityUsed * item.UnitCost,
|
||||||
|
TransactionDate = DateTime.UtcNow,
|
||||||
|
BalanceAfter = item.QuantityOnHand,
|
||||||
|
JobId = req.JobId,
|
||||||
|
Reference = $"Job {job.JobNumber}",
|
||||||
|
Notes = req.Notes?.Trim(),
|
||||||
|
CompanyId = item.CompanyId,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
await _unitOfWork.InventoryTransactions.AddAsync(txn);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
// GL: DR COGS, CR Inventory Asset
|
||||||
|
if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue)
|
||||||
|
{
|
||||||
|
var cost = req.QuantityUsed * (item.AverageCost > 0 ? item.AverageCost : item.UnitCost);
|
||||||
|
await _accountBalanceService.DebitAsync(item.CogsAccountId, cost);
|
||||||
|
await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Json(new
|
||||||
|
{
|
||||||
|
success = true,
|
||||||
|
message = $"Logged {req.QuantityUsed:N2} {item.UnitOfMeasure} of {item.Name}.",
|
||||||
|
newBalance = item.QuantityOnHand,
|
||||||
|
unitOfMeasure = item.UnitOfMeasure,
|
||||||
|
itemName = item.Name
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error logging material for job {JobId}", req.JobId);
|
||||||
|
return Json(new { success = false, message = "An error occurred. Please try again." });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class DeleteTimeEntryRequest { public int Id { get; set; } }
|
public class DeleteTimeEntryRequest { public int Id { get; set; } }
|
||||||
|
public class LogMaterialRequest
|
||||||
|
{
|
||||||
|
public int JobId { get; set; }
|
||||||
|
public int InventoryItemId { get; set; }
|
||||||
|
public decimal QuantityUsed { get; set; }
|
||||||
|
public string TransactionType { get; set; } = "JobUsage";
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
public class CreateReworkJobRequest { public int ReworkRecordId { get; set; } public string? Notes { get; set; } }
|
public class CreateReworkJobRequest { public int ReworkRecordId { get; set; } public string? Notes { get; set; } }
|
||||||
|
|
||||||
public class UpdateWorkerAssignmentRequest
|
public class UpdateWorkerAssignmentRequest
|
||||||
|
|||||||
@@ -90,8 +90,8 @@ public class JobsPriorityController : Controller
|
|||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// Get priorities and workers for modal options
|
// Get priorities and workers for modal options
|
||||||
var priorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
|
|
||||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId);
|
||||||
var workers = await _userManager.Users
|
var workers = await _userManager.Users
|
||||||
.Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null)
|
.Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null)
|
||||||
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
|
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
|
||||||
|
|||||||
@@ -16,15 +16,18 @@ public class MaintenanceController : Controller
|
|||||||
{
|
{
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
private readonly IMapper _mapper;
|
private readonly IMapper _mapper;
|
||||||
|
private readonly ITenantContext _tenantContext;
|
||||||
private readonly ILogger<MaintenanceController> _logger;
|
private readonly ILogger<MaintenanceController> _logger;
|
||||||
|
|
||||||
public MaintenanceController(
|
public MaintenanceController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
IMapper mapper,
|
IMapper mapper,
|
||||||
|
ITenantContext tenantContext,
|
||||||
ILogger<MaintenanceController> logger)
|
ILogger<MaintenanceController> logger)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_mapper = mapper;
|
_mapper = mapper;
|
||||||
|
_tenantContext = tenantContext;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -740,7 +743,8 @@ public class MaintenanceController : Controller
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task PopulateViewBagAsync(int? selectedEquipmentId = null)
|
private async Task PopulateViewBagAsync(int? selectedEquipmentId = null)
|
||||||
{
|
{
|
||||||
var equipment = await _unitOfWork.Equipment.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var equipment = await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId);
|
||||||
ViewBag.EquipmentList = new SelectList(
|
ViewBag.EquipmentList = new SelectList(
|
||||||
equipment.Where(e => e.IsActive).OrderBy(e => e.EquipmentName),
|
equipment.Where(e => e.IsActive).OrderBy(e => e.EquipmentName),
|
||||||
"Id",
|
"Id",
|
||||||
|
|||||||
@@ -179,8 +179,9 @@ public class OvenSchedulerController : Controller
|
|||||||
public async Task<IActionResult> Suggest([FromBody] SuggestRequest req)
|
public async Task<IActionResult> Suggest([FromBody] SuggestRequest req)
|
||||||
{
|
{
|
||||||
var goal = req?.OptimizationGoal ?? "maximize_throughput";
|
var goal = req?.OptimizationGoal ?? "maximize_throughput";
|
||||||
|
var suggestCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
var equipmentList = (await _unitOfWork.OvenCosts.GetAllAsync())
|
var equipmentList = (await _unitOfWork.OvenCosts.FindAsync(o => o.CompanyId == suggestCompanyId))
|
||||||
.Where(o => o.IsActive)
|
.Where(o => o.IsActive)
|
||||||
.OrderBy(o => o.DisplayOrder).ThenBy(o => o.Label)
|
.OrderBy(o => o.DisplayOrder).ThenBy(o => o.Label)
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -188,10 +189,11 @@ public class OvenSchedulerController : Controller
|
|||||||
if (!equipmentList.Any())
|
if (!equipmentList.Any())
|
||||||
return Json(new { success = false, error = "No active ovens found. Add Named Ovens in Settings → Operating Costs." });
|
return Json(new { success = false, error = "No active ovens found. Add Named Ovens in Settings → Operating Costs." });
|
||||||
|
|
||||||
var companyCosts = await _unitOfWork.CompanyOperatingCosts.GetAllAsync();
|
var companyCosts = await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == suggestCompanyId);
|
||||||
var defaultCycleMinutes = companyCosts.FirstOrDefault()?.DefaultOvenCycleMinutes ?? 45;
|
var defaultCycleMinutes = companyCosts.FirstOrDefault()?.DefaultOvenCycleMinutes ?? 45;
|
||||||
|
|
||||||
var queueJobs = (await _unitOfWork.Jobs.GetAllAsync(
|
var queueJobs = (await _unitOfWork.Jobs.FindAsync(
|
||||||
|
j => j.CompanyId == suggestCompanyId,
|
||||||
false,
|
false,
|
||||||
j => j.Customer,
|
j => j.Customer,
|
||||||
j => j.JobStatus,
|
j => j.JobStatus,
|
||||||
@@ -265,7 +267,8 @@ public class OvenSchedulerController : Controller
|
|||||||
if (req?.Batches == null || !req.Batches.Any())
|
if (req?.Batches == null || !req.Batches.Any())
|
||||||
return Json(new { success = false, error = "No batches provided." });
|
return Json(new { success = false, error = "No batches provided." });
|
||||||
|
|
||||||
var companyCosts = await _unitOfWork.CompanyOperatingCosts.GetAllAsync();
|
var acceptCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var companyCosts = await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == acceptCompanyId);
|
||||||
var defaultCycleMinutes = companyCosts.FirstOrDefault()?.DefaultOvenCycleMinutes ?? 45;
|
var defaultCycleMinutes = companyCosts.FirstOrDefault()?.DefaultOvenCycleMinutes ?? 45;
|
||||||
|
|
||||||
var createdBatches = new List<object>();
|
var createdBatches = new List<object>();
|
||||||
@@ -357,7 +360,8 @@ public class OvenSchedulerController : Controller
|
|||||||
if (oven == null)
|
if (oven == null)
|
||||||
return Json(new { success = false, error = "Oven not found." });
|
return Json(new { success = false, error = "Oven not found." });
|
||||||
|
|
||||||
var companyCosts = await _unitOfWork.CompanyOperatingCosts.GetAllAsync();
|
var createBatchCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var companyCosts = await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == createBatchCompanyId);
|
||||||
var defaultCycleMinutes = companyCosts.FirstOrDefault()?.DefaultOvenCycleMinutes ?? 45;
|
var defaultCycleMinutes = companyCosts.FirstOrDefault()?.DefaultOvenCycleMinutes ?? 45;
|
||||||
|
|
||||||
var batchNumber = await GenerateBatchNumberAsync();
|
var batchNumber = await GenerateBatchNumberAsync();
|
||||||
@@ -651,7 +655,8 @@ public class OvenSchedulerController : Controller
|
|||||||
if (inOvenStatus != null)
|
if (inOvenStatus != null)
|
||||||
{
|
{
|
||||||
var jobIds = batch.Items.Select(i => i.JobId).Distinct().ToHashSet();
|
var jobIds = batch.Items.Select(i => i.JobId).Distinct().ToHashSet();
|
||||||
var jobs = (await _unitOfWork.Jobs.GetAllAsync()).Where(j => jobIds.Contains(j.Id));
|
var startBatchCid = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var jobs = await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == startBatchCid && jobIds.Contains(j.Id));
|
||||||
foreach (var job in jobs)
|
foreach (var job in jobs)
|
||||||
job.JobStatusId = inOvenStatus.Id;
|
job.JobStatusId = inOvenStatus.Id;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ public class PricingTiersController : Controller
|
|||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
private readonly IMapper _mapper;
|
private readonly IMapper _mapper;
|
||||||
private readonly ILogger<PricingTiersController> _logger;
|
private readonly ILogger<PricingTiersController> _logger;
|
||||||
|
private readonly ITenantContext _tenantContext;
|
||||||
|
|
||||||
public PricingTiersController(IUnitOfWork unitOfWork, IMapper mapper, ILogger<PricingTiersController> logger)
|
public PricingTiersController(IUnitOfWork unitOfWork, IMapper mapper, ILogger<PricingTiersController> logger, ITenantContext tenantContext)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_mapper = mapper;
|
_mapper = mapper;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_tenantContext = tenantContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -27,8 +29,9 @@ public class PricingTiersController : Controller
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<IActionResult> Index()
|
public async Task<IActionResult> Index()
|
||||||
{
|
{
|
||||||
var tiers = await _unitOfWork.PricingTiers.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
var customers = await _unitOfWork.Customers.GetAllAsync();
|
var tiers = await _unitOfWork.PricingTiers.FindAsync(t => t.CompanyId == companyId);
|
||||||
|
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
|
||||||
|
|
||||||
var customerCountByTier = customers
|
var customerCountByTier = customers
|
||||||
.Where(c => c.PricingTierId.HasValue)
|
.Where(c => c.PricingTierId.HasValue)
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ public class QuotesController : Controller
|
|||||||
|
|
||||||
// Calibration nudge — suppress when named blast setups exist OR legacy CFM is set
|
// Calibration nudge — suppress when named blast setups exist OR legacy CFM is set
|
||||||
var costs = (await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == companyId)).FirstOrDefault();
|
var costs = (await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == companyId)).FirstOrDefault();
|
||||||
var hasNamedSetups = (await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive)).Any();
|
var hasNamedSetups = (await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive && b.CompanyId == companyId)).Any();
|
||||||
ViewBag.QuotingNotCalibrated = costs != null
|
ViewBag.QuotingNotCalibrated = costs != null
|
||||||
&& !hasNamedSetups
|
&& !hasNamedSetups
|
||||||
&& costs.CompressorCfm == 0
|
&& costs.CompressorCfm == 0
|
||||||
@@ -441,7 +441,7 @@ public class QuotesController : Controller
|
|||||||
ViewBag.Deposits = quoteDeposits;
|
ViewBag.Deposits = quoteDeposits;
|
||||||
|
|
||||||
// Customer list for inline customer-change dropdown
|
// Customer list for inline customer-change dropdown
|
||||||
var allCustomers = await _unitOfWork.Customers.GetAllAsync();
|
var allCustomers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == quote.CompanyId);
|
||||||
ViewBag.CustomerSelectList = allCustomers
|
ViewBag.CustomerSelectList = allCustomers
|
||||||
.Where(c => c.IsActive)
|
.Where(c => c.IsActive)
|
||||||
.Select(c => new SelectListItem
|
.Select(c => new SelectListItem
|
||||||
@@ -2430,7 +2430,7 @@ public class QuotesController : Controller
|
|||||||
ViewBag.QuotePhotosEnabled = quotePhotoMax != 0; // 0 = feature disabled for this plan
|
ViewBag.QuotePhotosEnabled = quotePhotoMax != 0; // 0 = feature disabled for this plan
|
||||||
|
|
||||||
// Customers
|
// Customers
|
||||||
var customers = await _unitOfWork.Customers.GetAllAsync();
|
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
|
||||||
ViewBag.Customers = customers
|
ViewBag.Customers = customers
|
||||||
.Select(c => new SelectListItem
|
.Select(c => new SelectListItem
|
||||||
{
|
{
|
||||||
@@ -2471,7 +2471,7 @@ public class QuotesController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Inventory coatings — include incoming items so they can be quoted while powder is in transit
|
// Inventory coatings — include incoming items so they can be quoted while powder is in transit
|
||||||
var inventory = await _unitOfWork.InventoryItems.GetAllAsync(false, i => i.InventoryCategory);
|
var inventory = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId, false, i => i.InventoryCategory);
|
||||||
ViewBag.InventoryCoatings = inventory
|
ViewBag.InventoryCoatings = inventory
|
||||||
.Where(i => i.IsActive && i.InventoryCategory?.IsActive == true && i.InventoryCategory.IsCoating)
|
.Where(i => i.IsActive && i.InventoryCategory?.IsActive == true && i.InventoryCategory.IsCoating)
|
||||||
.OrderBy(i => i.IsIncoming ? 1 : 0).ThenBy(i => i.InventoryCategory!.DisplayOrder).ThenBy(i => i.ColorName ?? i.Name)
|
.OrderBy(i => i.IsIncoming ? 1 : 0).ThenBy(i => i.InventoryCategory!.DisplayOrder).ThenBy(i => i.ColorName ?? i.Name)
|
||||||
@@ -2492,13 +2492,13 @@ public class QuotesController : Controller
|
|||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
// Vendors
|
// Vendors
|
||||||
var vendors = await _unitOfWork.Vendors.GetAllAsync(false);
|
var vendors = await _unitOfWork.Vendors.FindAsync(s => s.CompanyId == companyId, false);
|
||||||
ViewBag.Vendors = vendors
|
ViewBag.Vendors = vendors
|
||||||
.Where(s => s.IsActive).OrderBy(s => s.CompanyName)
|
.Where(s => s.IsActive).OrderBy(s => s.CompanyName)
|
||||||
.Select(s => new { value = s.Id.ToString(), text = s.CompanyName }).ToList();
|
.Select(s => new { value = s.Id.ToString(), text = s.CompanyName }).ToList();
|
||||||
|
|
||||||
// Catalog items
|
// Catalog items
|
||||||
var catalogItems = await _unitOfWork.CatalogItems.GetAllAsync(false, i => i.Category, i => i.Category.ParentCategory);
|
var catalogItems = await _unitOfWork.CatalogItems.FindAsync(i => i.CompanyId == companyId, false, i => i.Category, i => i.Category.ParentCategory);
|
||||||
ViewBag.CatalogItems = catalogItems
|
ViewBag.CatalogItems = catalogItems
|
||||||
.Where(i => i.IsActive)
|
.Where(i => i.IsActive)
|
||||||
.OrderBy(i => i.Category.DisplayOrder).ThenBy(i => i.DisplayOrder)
|
.OrderBy(i => i.Category.DisplayOrder).ThenBy(i => i.DisplayOrder)
|
||||||
@@ -2528,11 +2528,11 @@ public class QuotesController : Controller
|
|||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
// Prep services
|
// Prep services
|
||||||
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive);
|
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive && ps.CompanyId == companyId);
|
||||||
ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList();
|
ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList();
|
||||||
|
|
||||||
// Blast setups for wizard dropdown
|
// Blast setups for wizard dropdown
|
||||||
var blastSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive);
|
var blastSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive && b.CompanyId == companyId);
|
||||||
ViewBag.BlastSetups = blastSetups.OrderBy(b => b.DisplayOrder)
|
ViewBag.BlastSetups = blastSetups.OrderBy(b => b.DisplayOrder)
|
||||||
.Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault })
|
.Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault })
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -2599,7 +2599,8 @@ public class QuotesController : Controller
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task PopulatePricingTiersDropDownAsync()
|
private async Task PopulatePricingTiersDropDownAsync()
|
||||||
{
|
{
|
||||||
var pricingTiers = await _unitOfWork.PricingTiers.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var pricingTiers = await _unitOfWork.PricingTiers.FindAsync(pt => pt.CompanyId == companyId);
|
||||||
ViewBag.PricingTiers = pricingTiers.OrderBy(pt => pt.TierName)
|
ViewBag.PricingTiers = pricingTiers.OrderBy(pt => pt.TierName)
|
||||||
.Select(pt => new SelectListItem
|
.Select(pt => new SelectListItem
|
||||||
{
|
{
|
||||||
@@ -2825,9 +2826,9 @@ public class QuotesController : Controller
|
|||||||
// Do NOT assign fullItems to quote.QuoteItems — quote is a tracked entity and assigning
|
// Do NOT assign fullItems to quote.QuoteItems — quote is a tracked entity and assigning
|
||||||
// no-tracking children (which may share InventoryItem instances) causes EF identity conflicts.
|
// no-tracking children (which may share InventoryItem instances) causes EF identity conflicts.
|
||||||
|
|
||||||
// Get default job statuses and priorities
|
// Get default job statuses and priorities — scope to quote's company for defense-in-depth
|
||||||
var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
|
var jobStatuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == quote.CompanyId);
|
||||||
var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
|
var jobPriorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == quote.CompanyId);
|
||||||
var approvedStatus = jobStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Approved);
|
var approvedStatus = jobStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Approved);
|
||||||
var normalPriority = jobPriorities.FirstOrDefault(p => p.PriorityCode == "NORMAL");
|
var normalPriority = jobPriorities.FirstOrDefault(p => p.PriorityCode == "NORMAL");
|
||||||
var rushPriority = jobPriorities.FirstOrDefault(p => p.PriorityCode == "RUSH");
|
var rushPriority = jobPriorities.FirstOrDefault(p => p.PriorityCode == "RUSH");
|
||||||
@@ -3347,7 +3348,7 @@ public class QuotesController : Controller
|
|||||||
CompanyBlastSetup? selectedBlastSetup = null;
|
CompanyBlastSetup? selectedBlastSetup = null;
|
||||||
if (request.BlastSetupId.HasValue)
|
if (request.BlastSetupId.HasValue)
|
||||||
{
|
{
|
||||||
var setups = await _unitOfWork.BlastSetups.FindAsync(b => b.Id == request.BlastSetupId.Value && b.IsActive);
|
var setups = await _unitOfWork.BlastSetups.FindAsync(b => b.Id == request.BlastSetupId.Value && b.IsActive && b.CompanyId == companyId);
|
||||||
selectedBlastSetup = setups.FirstOrDefault();
|
selectedBlastSetup = setups.FirstOrDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ public class RecurringTemplatesController : Controller
|
|||||||
/// <summary>Lists all recurring templates for the current company, active first then by name.</summary>
|
/// <summary>Lists all recurring templates for the current company, active first then by name.</summary>
|
||||||
public async Task<IActionResult> Index()
|
public async Task<IActionResult> Index()
|
||||||
{
|
{
|
||||||
var templates = await _unitOfWork.RecurringTemplates.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var templates = await _unitOfWork.RecurringTemplates.FindAsync(t => t.CompanyId == companyId);
|
||||||
return View(templates.OrderByDescending(t => t.IsActive).ThenBy(t => t.Name).ToList());
|
return View(templates.OrderByDescending(t => t.IsActive).ThenBy(t => t.Name).ToList());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,11 +426,12 @@ public class RecurringTemplatesController : Controller
|
|||||||
/// <summary>Loads dropdowns for vendors, accounts, and payment methods into ViewBag.</summary>
|
/// <summary>Loads dropdowns for vendors, accounts, and payment methods into ViewBag.</summary>
|
||||||
private async Task PopulateDropDownsAsync()
|
private async Task PopulateDropDownsAsync()
|
||||||
{
|
{
|
||||||
var vendors = await _unitOfWork.Vendors.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId);
|
||||||
ViewBag.Vendors = vendors.OrderBy(v => v.CompanyName)
|
ViewBag.Vendors = vendors.OrderBy(v => v.CompanyName)
|
||||||
.Select(v => new SelectListItem(v.CompanyName, v.Id.ToString())).ToList();
|
.Select(v => new SelectListItem(v.CompanyName, v.Id.ToString())).ToList();
|
||||||
|
|
||||||
var accounts = await _unitOfWork.Accounts.GetAllAsync();
|
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId);
|
||||||
ViewBag.APAccounts = accounts
|
ViewBag.APAccounts = accounts
|
||||||
.Where(a => a.AccountSubType == AccountSubType.AccountsPayable)
|
.Where(a => a.AccountSubType == AccountSubType.AccountsPayable)
|
||||||
.OrderBy(a => a.AccountNumber)
|
.OrderBy(a => a.AccountNumber)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ using PowderCoating.Core.Interfaces;
|
|||||||
using PowderCoating.Core.Entities;
|
using PowderCoating.Core.Entities;
|
||||||
using PowderCoating.Shared.Constants;
|
using PowderCoating.Shared.Constants;
|
||||||
using PowderCoating.Web.ViewModels.Reports;
|
using PowderCoating.Web.ViewModels.Reports;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
namespace PowderCoating.Web.Controllers;
|
namespace PowderCoating.Web.Controllers;
|
||||||
|
|
||||||
@@ -25,8 +26,9 @@ public class ReportsController : Controller
|
|||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
private readonly IAccountingAiService _accountingAi;
|
private readonly IAccountingAiService _accountingAi;
|
||||||
private readonly IAiUsageLogger _usageLogger;
|
private readonly IAiUsageLogger _usageLogger;
|
||||||
|
private readonly ITenantContext _tenantContext;
|
||||||
|
|
||||||
public ReportsController(IUnitOfWork unitOfWork, ILogger<ReportsController> logger, IFinancialReportService financialReports, IOperationalReportService operationalReports, IPdfService pdfService, UserManager<ApplicationUser> userManager, IAccountingAiService accountingAi, IAiUsageLogger usageLogger)
|
public ReportsController(IUnitOfWork unitOfWork, ILogger<ReportsController> logger, IFinancialReportService financialReports, IOperationalReportService operationalReports, IPdfService pdfService, UserManager<ApplicationUser> userManager, IAccountingAiService accountingAi, IAiUsageLogger usageLogger, ITenantContext tenantContext)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -36,6 +38,7 @@ public class ReportsController : Controller
|
|||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_accountingAi = accountingAi;
|
_accountingAi = accountingAi;
|
||||||
_usageLogger = usageLogger;
|
_usageLogger = usageLogger;
|
||||||
|
_tenantContext = tenantContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -79,27 +82,26 @@ public class ReportsController : Controller
|
|||||||
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
|
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
|
||||||
var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
|
var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
|
||||||
"MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" };
|
"MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" };
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
// Load only necessary data - optimized with filtering and minimal eager loading
|
// Load only necessary data — all explicitly scoped to this company
|
||||||
// Jobs: Load all jobs (we need various status filters and the collection is needed for job status distribution)
|
var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority, j => j.AssignedUser)).ToList();
|
||||||
// Note: Date filtering would exclude data needed for jobsByStatus calculation
|
|
||||||
var jobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority, j => j.AssignedUser)).ToList();
|
|
||||||
|
|
||||||
// Quotes: Load all quotes (needed for quote status distribution and conversion funnel)
|
// Quotes: Load all quotes (needed for quote status distribution and conversion funnel)
|
||||||
var quotes = (await _unitOfWork.Quotes.GetAllAsync(false, q => q.Customer, q => q.QuoteStatus)).ToList();
|
var quotes = (await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId, false, q => q.Customer, q => q.QuoteStatus)).ToList();
|
||||||
|
|
||||||
// Customers: Load all (needed for active count and customer creation trend across all months)
|
// Customers: Load all (needed for active count and customer creation trend across all months)
|
||||||
var customers = (await _unitOfWork.Customers.GetAllAsync()).ToList();
|
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)).ToList();
|
||||||
|
|
||||||
// Equipment: Load all for status distribution
|
// Equipment: Load all for status distribution
|
||||||
var equipment = (await _unitOfWork.Equipment.GetAllAsync()).ToList();
|
var equipment = (await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId)).ToList();
|
||||||
|
|
||||||
// Inventory: Load all for low stock analysis
|
// Inventory: Load all for low stock analysis
|
||||||
var inventory = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList();
|
var inventory = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
|
||||||
|
|
||||||
// Appointments: Filter to relevant date range at DB level
|
// Appointments: Filter to relevant date range at DB level
|
||||||
var appointments = (await _unitOfWork.Appointments.FindAsync(
|
var appointments = (await _unitOfWork.Appointments.FindAsync(
|
||||||
a => a.ScheduledStartTime >= startDate,
|
a => a.CompanyId == companyId && a.ScheduledStartTime >= startDate,
|
||||||
false,
|
false,
|
||||||
a => a.Customer,
|
a => a.Customer,
|
||||||
a => a.AppointmentType,
|
a => a.AppointmentType,
|
||||||
@@ -108,7 +110,7 @@ public class ReportsController : Controller
|
|||||||
// Users with assigned jobs/appointments will be loaded below when building worker stats
|
// Users with assigned jobs/appointments will be loaded below when building worker stats
|
||||||
|
|
||||||
// CatalogItems: Load all for category distribution
|
// CatalogItems: Load all for category distribution
|
||||||
var catalogItems = (await _unitOfWork.CatalogItems.GetAllAsync(false, c => c.Category)).ToList();
|
var catalogItems = (await _unitOfWork.CatalogItems.FindAsync(ci => ci.CompanyId == companyId, false, c => c.Category)).ToList();
|
||||||
|
|
||||||
// === OVERVIEW METRICS ===
|
// === OVERVIEW METRICS ===
|
||||||
var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
||||||
@@ -382,7 +384,7 @@ public class ReportsController : Controller
|
|||||||
.ToDictionary(g => g.Key, g => g.Count());
|
.ToDictionary(g => g.Key, g => g.Count());
|
||||||
|
|
||||||
// === FINANCIAL ANALYTICS ===
|
// === FINANCIAL ANALYTICS ===
|
||||||
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList();
|
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
|
||||||
var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
|
var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
|
||||||
|
|
||||||
var totalInvoiced = activeInvoices.Sum(i => i.Total);
|
var totalInvoiced = activeInvoices.Sum(i => i.Total);
|
||||||
@@ -781,7 +783,7 @@ public class ReportsController : Controller
|
|||||||
|
|
||||||
// === POWDER CONSUMPTION VS PURCHASE ===
|
// === POWDER CONSUMPTION VS PURCHASE ===
|
||||||
var allInventoryTransactions = (await _unitOfWork.InventoryTransactions
|
var allInventoryTransactions = (await _unitOfWork.InventoryTransactions
|
||||||
.GetAllAsync(false, t => t.InventoryItem))
|
.FindAsync(t => t.CompanyId == companyId, false, t => t.InventoryItem))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var powderConsumptionItems = allInventoryTransactions
|
var powderConsumptionItems = allInventoryTransactions
|
||||||
@@ -1309,14 +1311,15 @@ public class ReportsController : Controller
|
|||||||
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
|
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
|
||||||
var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
|
var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
|
||||||
"MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" };
|
"MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" };
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
var jobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).ToList();
|
var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).ToList();
|
||||||
var customers = (await _unitOfWork.Customers.GetAllAsync()).ToList();
|
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)).ToList();
|
||||||
var quotes = (await _unitOfWork.Quotes.GetAllAsync(false, q => q.QuoteStatus)).ToList();
|
var quotes = (await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId, false, q => q.QuoteStatus)).ToList();
|
||||||
var equipment = (await _unitOfWork.Equipment.GetAllAsync()).ToList();
|
var equipment = (await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId)).ToList();
|
||||||
var inventory = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList();
|
var inventory = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
|
||||||
var allAppointments = await _unitOfWork.Appointments.GetAllAsync(false, a => a.AppointmentStatus);
|
var allAppointments = await _unitOfWork.Appointments.FindAsync(a => a.CompanyId == companyId && a.ScheduledStartTime >= startDate, false, a => a.AppointmentStatus);
|
||||||
var appointments = allAppointments.Where(a => a.ScheduledStartTime >= startDate).ToList();
|
var appointments = allAppointments.ToList();
|
||||||
|
|
||||||
var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
||||||
var activeJobs = jobs.Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
var activeJobs = jobs.Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
||||||
@@ -1384,7 +1387,8 @@ public class ReportsController : Controller
|
|||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var startDate = now.AddMonths(-months);
|
var startDate = now.AddMonths(-months);
|
||||||
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
|
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
|
||||||
var jobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).ToList();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).ToList();
|
||||||
var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
||||||
var inRange = completedJobs.Where(j => j.UpdatedAt >= startDate).ToList();
|
var inRange = completedJobs.Where(j => j.UpdatedAt >= startDate).ToList();
|
||||||
var byMonth = inRange.GroupBy(j => new DateTime(j.UpdatedAt!.Value.Year, j.UpdatedAt.Value.Month, 1)).ToDictionary(g => g.Key, g => g.ToList());
|
var byMonth = inRange.GroupBy(j => new DateTime(j.UpdatedAt!.Value.Year, j.UpdatedAt.Value.Month, 1)).ToDictionary(g => g.Key, g => g.ToList());
|
||||||
@@ -1430,12 +1434,13 @@ public class ReportsController : Controller
|
|||||||
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
|
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
|
||||||
var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
|
var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
|
||||||
"MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" };
|
"MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" };
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
var jobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.JobStatus, j => j.JobPriority, j => j.AssignedUser)).ToList();
|
var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.JobStatus, j => j.JobPriority, j => j.AssignedUser)).ToList();
|
||||||
var equipment = (await _unitOfWork.Equipment.GetAllAsync()).ToList();
|
var equipment = (await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId)).ToList();
|
||||||
var inventory = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList();
|
var inventory = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
|
||||||
var allAppts = await _unitOfWork.Appointments.GetAllAsync(false, a => a.AppointmentType, a => a.AppointmentStatus);
|
var allAppts = await _unitOfWork.Appointments.FindAsync(a => a.CompanyId == companyId && a.ScheduledStartTime >= startDate, false, a => a.AppointmentType, a => a.AppointmentStatus);
|
||||||
var appointments = allAppts.Where(a => a.ScheduledStartTime >= startDate).ToList();
|
var appointments = allAppts.ToList();
|
||||||
|
|
||||||
var activeJobs = jobs.Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
var activeJobs = jobs.Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
||||||
var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
||||||
@@ -1483,10 +1488,11 @@ public class ReportsController : Controller
|
|||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var startDate = now.AddMonths(-months);
|
var startDate = now.AddMonths(-months);
|
||||||
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
|
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
|
||||||
var customers = (await _unitOfWork.Customers.GetAllAsync()).ToList();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
var quotes = (await _unitOfWork.Quotes.GetAllAsync(false, q => q.QuoteStatus)).ToList();
|
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)).ToList();
|
||||||
var catalogItems = (await _unitOfWork.CatalogItems.GetAllAsync(false, c => c.Category)).ToList();
|
var quotes = (await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId, false, q => q.QuoteStatus)).ToList();
|
||||||
var completedJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority))
|
var catalogItems = (await _unitOfWork.CatalogItems.FindAsync(ci => ci.CompanyId == companyId, false, c => c.Category)).ToList();
|
||||||
|
var completedJobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority))
|
||||||
.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
||||||
|
|
||||||
var customersByMonth = customers.Where(c => c.CreatedAt >= startDate).GroupBy(c => new DateTime(c.CreatedAt.Year, c.CreatedAt.Month, 1)).ToDictionary(g => g.Key, g => g.Count());
|
var customersByMonth = customers.Where(c => c.CreatedAt >= startDate).GroupBy(c => new DateTime(c.CreatedAt.Year, c.CreatedAt.Month, 1)).ToDictionary(g => g.Key, g => g.Count());
|
||||||
@@ -1523,7 +1529,8 @@ public class ReportsController : Controller
|
|||||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var today = DateTime.Today;
|
var today = DateTime.Today;
|
||||||
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
|
||||||
var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
|
var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
|
||||||
var outstandingInvoices = activeInvoices.Where(i => i.Status != InvoiceStatus.Paid && i.Total > i.AmountPaid).ToList();
|
var outstandingInvoices = activeInvoices.Where(i => i.Status != InvoiceStatus.Paid && i.Total > i.AmountPaid).ToList();
|
||||||
var overdueInvoices = activeInvoices.Where(i => i.Status != InvoiceStatus.Paid && i.DueDate.HasValue && i.DueDate.Value < today).ToList();
|
var overdueInvoices = activeInvoices.Where(i => i.Status != InvoiceStatus.Paid && i.DueDate.HasValue && i.DueDate.Value < today).ToList();
|
||||||
@@ -1574,7 +1581,8 @@ public class ReportsController : Controller
|
|||||||
|
|
||||||
var monthLabels = new List<string>(); var monthlyBillsPaid = new List<decimal>(); var monthlyDirectExpenses = new List<decimal>();
|
var monthLabels = new List<string>(); var monthlyBillsPaid = new List<decimal>(); var monthlyDirectExpenses = new List<decimal>();
|
||||||
// Also load collected payments for P&L comparison
|
// Also load collected payments for P&L comparison
|
||||||
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Payments)).ToList();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Payments)).ToList();
|
||||||
var paymentsByMonth = allInvoices.SelectMany(i => i.Payments.Where(p => !p.IsDeleted)).GroupBy(p => new DateTime(p.PaymentDate.Year, p.PaymentDate.Month, 1)).ToDictionary(g => g.Key, g => g.ToList());
|
var paymentsByMonth = allInvoices.SelectMany(i => i.Payments.Where(p => !p.IsDeleted)).GroupBy(p => new DateTime(p.PaymentDate.Year, p.PaymentDate.Month, 1)).ToDictionary(g => g.Key, g => g.ToList());
|
||||||
var plRevenue = new List<decimal>(); var plExpenses = new List<decimal>(); var plNet = new List<decimal>();
|
var plRevenue = new List<decimal>(); var plExpenses = new List<decimal>(); var plNet = new List<decimal>();
|
||||||
for (var i = months - 1; i >= 0; i--)
|
for (var i = months - 1; i >= 0; i--)
|
||||||
@@ -1609,8 +1617,10 @@ public class ReportsController : Controller
|
|||||||
{
|
{
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var startDate = now.AddMonths(-months);
|
var startDate = now.AddMonths(-months);
|
||||||
var powderTransactions = (await _unitOfWork.InventoryTransactions.GetAllAsync(false, t => t.InventoryItem))
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
.Where(t => t.TransactionType == InventoryTransactionType.JobUsage && t.TransactionDate >= startDate).ToList();
|
var powderTransactions = (await _unitOfWork.InventoryTransactions.FindAsync(
|
||||||
|
t => t.CompanyId == companyId && t.TransactionType == InventoryTransactionType.JobUsage && t.TransactionDate >= startDate, false, t => t.InventoryItem))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
var topColors = powderTransactions.Where(t => t.InventoryItem != null).GroupBy(t => t.InventoryItemId)
|
var topColors = powderTransactions.Where(t => t.InventoryItem != null).GroupBy(t => t.InventoryItemId)
|
||||||
.Select(g => new PowderUsageByColorItem { InventoryItemId = g.Key, ColorName = g.First().InventoryItem!.ColorName ?? g.First().InventoryItem.Name, ColorCode = g.First().InventoryItem!.ColorCode, SKU = g.First().InventoryItem!.SKU, Manufacturer = g.First().InventoryItem!.Manufacturer, TotalLbsUsed = g.Sum(t => Math.Abs(t.Quantity)), TotalCost = g.Sum(t => Math.Abs(t.TotalCost)), JobCount = g.Where(t => !string.IsNullOrEmpty(t.Reference)).Select(t => t.Reference).Distinct().Count() })
|
.Select(g => new PowderUsageByColorItem { InventoryItemId = g.Key, ColorName = g.First().InventoryItem!.ColorName ?? g.First().InventoryItem.Name, ColorCode = g.First().InventoryItem!.ColorCode, SKU = g.First().InventoryItem!.SKU, Manufacturer = g.First().InventoryItem!.Manufacturer, TotalLbsUsed = g.Sum(t => Math.Abs(t.Quantity)), TotalCost = g.Sum(t => Math.Abs(t.TotalCost)), JobCount = g.Where(t => !string.IsNullOrEmpty(t.Reference)).Select(t => t.Reference).Distinct().Count() })
|
||||||
@@ -1631,7 +1641,8 @@ public class ReportsController : Controller
|
|||||||
/// <summary>Sales by Customer report — all active (non-voided) invoices grouped by customer, sorted by total invoiced.</summary>
|
/// <summary>Sales by Customer report — all active (non-voided) invoices grouped by customer, sorted by total invoiced.</summary>
|
||||||
public async Task<IActionResult> SalesByCustomer(int months = 6)
|
public async Task<IActionResult> SalesByCustomer(int months = 6)
|
||||||
{
|
{
|
||||||
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
|
||||||
var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
|
var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
|
||||||
var items = activeInvoices.Where(i => i.Customer != null)
|
var items = activeInvoices.Where(i => i.Customer != null)
|
||||||
.GroupBy(i => new { i.CustomerId, Name = i.Customer!.IsCommercial ? i.Customer.CompanyName : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim(), i.Customer.IsCommercial })
|
.GroupBy(i => new { i.CustomerId, Name = i.Customer!.IsCommercial ? i.Customer.CompanyName : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim(), i.Customer.IsCommercial })
|
||||||
@@ -1650,8 +1661,9 @@ public class ReportsController : Controller
|
|||||||
{
|
{
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
|
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
|
||||||
var customers = (await _unitOfWork.Customers.GetAllAsync()).ToList();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
var completedJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.JobStatus)).Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)).ToList();
|
||||||
|
var completedJobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.JobStatus)).Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
||||||
var items = customers.Where(c => c.IsActive).Select(c =>
|
var items = customers.Where(c => c.IsActive).Select(c =>
|
||||||
{
|
{
|
||||||
var cJobs = completedJobs.Where(j => j.CustomerId == c.Id).ToList();
|
var cJobs = completedJobs.Where(j => j.CustomerId == c.Id).ToList();
|
||||||
@@ -1682,7 +1694,8 @@ public class ReportsController : Controller
|
|||||||
{
|
{
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
|
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
|
||||||
var completedJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.JobStatus)).Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode) && j.CompletedDate.HasValue).ToList();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var completedJobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.JobStatus)).Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode) && j.CompletedDate.HasValue).ToList();
|
||||||
var allStatusHistory = await _operationalReports.GetAllJobStatusHistoryAsync();
|
var allStatusHistory = await _operationalReports.GetAllJobStatusHistoryAsync();
|
||||||
var historyByJob = allStatusHistory.GroupBy(h => h.JobId).ToDictionary(g => g.Key, g => g.OrderBy(h => h.ChangedDate).ToList());
|
var historyByJob = allStatusHistory.GroupBy(h => h.JobId).ToDictionary(g => g.Key, g => g.OrderBy(h => h.ChangedDate).ToList());
|
||||||
var statusDisplayOrder = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK" };
|
var statusDisplayOrder = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK" };
|
||||||
@@ -1720,7 +1733,8 @@ public class ReportsController : Controller
|
|||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var today = DateTime.Today;
|
var today = DateTime.Today;
|
||||||
var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" };
|
var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" };
|
||||||
var activeJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var activeJobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
||||||
var items = activeJobs.Select(j => new JobStatusAgingItem
|
var items = activeJobs.Select(j => new JobStatusAgingItem
|
||||||
{
|
{
|
||||||
JobId = j.Id, JobNumber = j.JobNumber, CustomerName = j.Customer?.IsCommercial == true ? j.Customer.CompanyName ?? "Unknown" : $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim(),
|
JobId = j.Id, JobNumber = j.JobNumber, CustomerName = j.Customer?.IsCommercial == true ? j.Customer.CompanyName ?? "Unknown" : $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim(),
|
||||||
@@ -1740,7 +1754,8 @@ public class ReportsController : Controller
|
|||||||
{
|
{
|
||||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||||
var today = DateTime.Today;
|
var today = DateTime.Today;
|
||||||
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
|
||||||
var items = allInvoices.Where(i => i.Customer != null && i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff && i.Status != InvoiceStatus.Paid)
|
var items = allInvoices.Where(i => i.Customer != null && i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff && i.Status != InvoiceStatus.Paid)
|
||||||
.Select(i =>
|
.Select(i =>
|
||||||
{
|
{
|
||||||
@@ -1758,7 +1773,8 @@ public class ReportsController : Controller
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<IActionResult> PowderConsumption(int months = 6)
|
public async Task<IActionResult> PowderConsumption(int months = 6)
|
||||||
{
|
{
|
||||||
var allTx = (await _unitOfWork.InventoryTransactions.GetAllAsync(false, t => t.InventoryItem)).ToList();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allTx = (await _unitOfWork.InventoryTransactions.FindAsync(t => t.CompanyId == companyId, false, t => t.InventoryItem)).ToList();
|
||||||
var items = allTx.Where(t => t.InventoryItem != null)
|
var items = allTx.Where(t => t.InventoryItem != null)
|
||||||
.GroupBy(t => new { t.InventoryItemId, t.InventoryItem!.Name, t.InventoryItem.SKU, t.InventoryItem.ColorName, t.InventoryItem.ColorCode, t.InventoryItem.Manufacturer })
|
.GroupBy(t => new { t.InventoryItemId, t.InventoryItem!.Name, t.InventoryItem.SKU, t.InventoryItem.ColorName, t.InventoryItem.ColorCode, t.InventoryItem.Manufacturer })
|
||||||
.Select(g => new PowderConsumptionItem { InventoryItemId = g.Key.InventoryItemId, ItemName = g.Key.Name, SKU = g.Key.SKU, ColorName = g.Key.ColorName, ColorCode = g.Key.ColorCode, Manufacturer = g.Key.Manufacturer, TotalPurchasedLbs = g.Where(t => t.TransactionType == InventoryTransactionType.Purchase || t.TransactionType == InventoryTransactionType.Initial).Sum(t => t.Quantity), TotalConsumedLbs = g.Where(t => t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Waste).Sum(t => Math.Abs(t.Quantity)), PurchaseCount = g.Count(t => t.TransactionType == InventoryTransactionType.Purchase), UsageJobCount = g.Where(t => t.TransactionType == InventoryTransactionType.JobUsage && !string.IsNullOrEmpty(t.Reference)).Select(t => t.Reference).Distinct().Count() })
|
.Select(g => new PowderConsumptionItem { InventoryItemId = g.Key.InventoryItemId, ItemName = g.Key.Name, SKU = g.Key.SKU, ColorName = g.Key.ColorName, ColorCode = g.Key.ColorCode, Manufacturer = g.Key.Manufacturer, TotalPurchasedLbs = g.Where(t => t.TransactionType == InventoryTransactionType.Purchase || t.TransactionType == InventoryTransactionType.Initial).Sum(t => t.Quantity), TotalConsumedLbs = g.Where(t => t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Waste).Sum(t => Math.Abs(t.Quantity)), PurchaseCount = g.Count(t => t.TransactionType == InventoryTransactionType.Purchase), UsageJobCount = g.Where(t => t.TransactionType == InventoryTransactionType.JobUsage && !string.IsNullOrEmpty(t.Reference)).Select(t => t.Reference).Distinct().Count() })
|
||||||
@@ -1776,8 +1792,9 @@ public class ReportsController : Controller
|
|||||||
public async Task<IActionResult> InventoryTurnover(int months = 6)
|
public async Task<IActionResult> InventoryTurnover(int months = 6)
|
||||||
{
|
{
|
||||||
var daysInPeriod = months * 30.0;
|
var daysInPeriod = months * 30.0;
|
||||||
var inventory = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
var allTx = (await _unitOfWork.InventoryTransactions.GetAllAsync(false, t => t.InventoryItem)).ToList();
|
var inventory = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
|
||||||
|
var allTx = (await _unitOfWork.InventoryTransactions.FindAsync(t => t.CompanyId == companyId, false, t => t.InventoryItem)).ToList();
|
||||||
var items = inventory.Where(i => i.IsActive).Select(i =>
|
var items = inventory.Where(i => i.IsActive).Select(i =>
|
||||||
{
|
{
|
||||||
var iTx = allTx.Where(t => t.InventoryItemId == i.Id).ToList();
|
var iTx = allTx.Where(t => t.InventoryItemId == i.Id).ToList();
|
||||||
@@ -1835,8 +1852,9 @@ public class ReportsController : Controller
|
|||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var today = DateTime.Today;
|
var today = DateTime.Today;
|
||||||
|
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
// Load invoices for AR data
|
// Load invoices for AR data
|
||||||
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList();
|
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
|
||||||
var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
|
var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
|
||||||
var outstandingInvoices = activeInvoices.Where(i => i.BalanceDue > 0 && i.Status != InvoiceStatus.Paid).ToList();
|
var outstandingInvoices = activeInvoices.Where(i => i.BalanceDue > 0 && i.Status != InvoiceStatus.Paid).ToList();
|
||||||
|
|
||||||
@@ -1930,13 +1948,14 @@ public class ReportsController : Controller
|
|||||||
var companyName = await GetCompanyNameAsync();
|
var companyName = await GetCompanyNameAsync();
|
||||||
var today = DateTime.Today;
|
var today = DateTime.Today;
|
||||||
|
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
// Open AR invoices
|
// Open AR invoices
|
||||||
var openInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments))
|
var openInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments))
|
||||||
.Where(i => i.BalanceDue > 0 && i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff && i.Status != InvoiceStatus.Paid)
|
.Where(i => i.BalanceDue > 0 && i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff && i.Status != InvoiceStatus.Paid)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// Compute avg days to pay per customer from paid invoices
|
// Compute avg days to pay per customer from paid invoices
|
||||||
var paidInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Payments))
|
var paidInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Payments))
|
||||||
.Where(i => i.Status == InvoiceStatus.Paid && i.InvoiceDate != default)
|
.Where(i => i.Status == InvoiceStatus.Paid && i.InvoiceDate != default)
|
||||||
.ToList();
|
.ToList();
|
||||||
var avgDaysByCustomer = paidInvoices
|
var avgDaysByCustomer = paidInvoices
|
||||||
@@ -2137,7 +2156,8 @@ public class ReportsController : Controller
|
|||||||
var companyName = await GetCompanyNameAsync();
|
var companyName = await GetCompanyNameAsync();
|
||||||
var today = DateTime.Today;
|
var today = DateTime.Today;
|
||||||
|
|
||||||
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
|
||||||
var activeInvoices = allInvoices.Where(i =>
|
var activeInvoices = allInvoices.Where(i =>
|
||||||
i.Status != InvoiceStatus.Voided &&
|
i.Status != InvoiceStatus.Voided &&
|
||||||
i.Status != InvoiceStatus.WrittenOff).ToList();
|
i.Status != InvoiceStatus.WrittenOff).ToList();
|
||||||
@@ -2256,8 +2276,9 @@ public class ReportsController : Controller
|
|||||||
var companyName = await GetCompanyNameAsync();
|
var companyName = await GetCompanyNameAsync();
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var startOfYear = new DateTime(now.Year, 1, 1);
|
var startOfYear = new DateTime(now.Year, 1, 1);
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments))
|
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments))
|
||||||
.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff)
|
.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ namespace PowderCoating.Web.Controllers;
|
|||||||
public class SmsConsentAuditController : Controller
|
public class SmsConsentAuditController : Controller
|
||||||
{
|
{
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
|
private readonly ITenantContext _tenantContext;
|
||||||
private readonly ILogger<SmsConsentAuditController> _logger;
|
private readonly ILogger<SmsConsentAuditController> _logger;
|
||||||
|
|
||||||
public SmsConsentAuditController(IUnitOfWork unitOfWork, ILogger<SmsConsentAuditController> logger)
|
public SmsConsentAuditController(IUnitOfWork unitOfWork, ITenantContext tenantContext, ILogger<SmsConsentAuditController> logger)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
|
_tenantContext = tenantContext;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +32,8 @@ public class SmsConsentAuditController : Controller
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var allCustomers = await _unitOfWork.Customers.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allCustomers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(search))
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
{
|
{
|
||||||
@@ -98,7 +101,8 @@ public class SmsConsentAuditController : Controller
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var customers = (await _unitOfWork.Customers.GetAllAsync())
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId))
|
||||||
.OrderBy(c => c.CompanyName ?? c.ContactLastName ?? c.ContactFirstName)
|
.OrderBy(c => c.CompanyName ?? c.ContactLastName ?? c.ContactFirstName)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ public class TaxRatesController : Controller
|
|||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> Index()
|
public async Task<IActionResult> Index()
|
||||||
{
|
{
|
||||||
var rates = await _unitOfWork.TaxRates.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var rates = await _unitOfWork.TaxRates.FindAsync(r => r.CompanyId == companyId);
|
||||||
return View(rates.OrderBy(r => r.Name).ToList());
|
return View(rates.OrderBy(r => r.Name).ToList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,8 @@ public class ToolsController : Controller
|
|||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> GetImportAccounts()
|
public async Task<IActionResult> GetImportAccounts()
|
||||||
{
|
{
|
||||||
var allAccounts = await _unitOfWork.Accounts.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId);
|
||||||
|
|
||||||
var revenue = allAccounts
|
var revenue = allAccounts
|
||||||
.Where(a => a.AccountType == AccountType.Revenue && a.IsActive)
|
.Where(a => a.AccountType == AccountType.Revenue && a.IsActive)
|
||||||
@@ -123,7 +124,8 @@ public class ToolsController : Controller
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task PopulateImportAccountDropdownsAsync()
|
private async Task PopulateImportAccountDropdownsAsync()
|
||||||
{
|
{
|
||||||
var allAccounts = await _unitOfWork.Accounts.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId);
|
||||||
|
|
||||||
var revenueAccounts = allAccounts
|
var revenueAccounts = allAccounts
|
||||||
.Where(a => a.AccountType == AccountType.Revenue && a.IsActive)
|
.Where(a => a.AccountType == AccountType.Revenue && a.IsActive)
|
||||||
@@ -1102,7 +1104,7 @@ public class ToolsController : Controller
|
|||||||
|
|
||||||
// Validate account IDs belong to this company — stale page load can produce IDs
|
// Validate account IDs belong to this company — stale page load can produce IDs
|
||||||
// that were valid before a data reset but no longer exist.
|
// that were valid before a data reset but no longer exist.
|
||||||
var validAccountIds = (await _unitOfWork.Accounts.GetAllAsync())
|
var validAccountIds = (await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value))
|
||||||
.Select(a => a.Id).ToHashSet();
|
.Select(a => a.Id).ToHashSet();
|
||||||
if (revenueAccountId.HasValue && !validAccountIds.Contains(revenueAccountId.Value))
|
if (revenueAccountId.HasValue && !validAccountIds.Contains(revenueAccountId.Value))
|
||||||
revenueAccountId = null;
|
revenueAccountId = null;
|
||||||
@@ -1167,7 +1169,7 @@ public class ToolsController : Controller
|
|||||||
|
|
||||||
// Validate account IDs belong to this company — stale page load can produce IDs
|
// Validate account IDs belong to this company — stale page load can produce IDs
|
||||||
// that were valid before a data reset but no longer exist.
|
// that were valid before a data reset but no longer exist.
|
||||||
var validAccountIds = (await _unitOfWork.Accounts.GetAllAsync())
|
var validAccountIds = (await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value))
|
||||||
.Select(a => a.Id).ToHashSet();
|
.Select(a => a.Id).ToHashSet();
|
||||||
if (inventoryAccountId.HasValue && !validAccountIds.Contains(inventoryAccountId.Value))
|
if (inventoryAccountId.HasValue && !validAccountIds.Contains(inventoryAccountId.Value))
|
||||||
inventoryAccountId = null;
|
inventoryAccountId = null;
|
||||||
@@ -1939,7 +1941,7 @@ public class ToolsController : Controller
|
|||||||
using (var archive = new System.IO.Compression.ZipArchive(memoryStream, System.IO.Compression.ZipArchiveMode.Create, true))
|
using (var archive = new System.IO.Compression.ZipArchive(memoryStream, System.IO.Compression.ZipArchiveMode.Create, true))
|
||||||
{
|
{
|
||||||
// 1. Customers
|
// 1. Customers
|
||||||
var customers = await _unitOfWork.Customers.GetAllAsync();
|
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId.Value);
|
||||||
var customersCsv = GenerateCustomersCsv(customers);
|
var customersCsv = GenerateCustomersCsv(customers);
|
||||||
var customersEntry = archive.CreateEntry($"customers_{timestamp}.csv");
|
var customersEntry = archive.CreateEntry($"customers_{timestamp}.csv");
|
||||||
using (var entryStream = customersEntry.Open())
|
using (var entryStream = customersEntry.Open())
|
||||||
@@ -1949,7 +1951,7 @@ public class ToolsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Quotes
|
// 2. Quotes
|
||||||
var quotes = await _unitOfWork.Quotes.GetAllAsync(false, q => q.Customer, q => q.QuoteStatus);
|
var quotes = await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId.Value, false, q => q.Customer, q => q.QuoteStatus);
|
||||||
var quotesCsv = GenerateQuotesCsv(quotes);
|
var quotesCsv = GenerateQuotesCsv(quotes);
|
||||||
var quotesEntry = archive.CreateEntry($"quotes_{timestamp}.csv");
|
var quotesEntry = archive.CreateEntry($"quotes_{timestamp}.csv");
|
||||||
using (var entryStream = quotesEntry.Open())
|
using (var entryStream = quotesEntry.Open())
|
||||||
@@ -1959,7 +1961,7 @@ public class ToolsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Jobs
|
// 3. Jobs
|
||||||
var jobs = await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority);
|
var jobs = await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId.Value, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority);
|
||||||
var jobsCsv = GenerateJobsCsv(jobs);
|
var jobsCsv = GenerateJobsCsv(jobs);
|
||||||
var jobsEntry = archive.CreateEntry($"jobs_{timestamp}.csv");
|
var jobsEntry = archive.CreateEntry($"jobs_{timestamp}.csv");
|
||||||
using (var entryStream = jobsEntry.Open())
|
using (var entryStream = jobsEntry.Open())
|
||||||
@@ -1969,7 +1971,7 @@ public class ToolsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Appointments
|
// 4. Appointments
|
||||||
var appointments = await _unitOfWork.Appointments.GetAllAsync(false,
|
var appointments = await _unitOfWork.Appointments.FindAsync(a => a.CompanyId == companyId.Value, false,
|
||||||
a => a.Customer, a => a.AppointmentType, a => a.AppointmentStatus);
|
a => a.Customer, a => a.AppointmentType, a => a.AppointmentStatus);
|
||||||
var appointmentsCsv = GenerateAppointmentsCsv(appointments);
|
var appointmentsCsv = GenerateAppointmentsCsv(appointments);
|
||||||
var appointmentsEntry = archive.CreateEntry($"appointments_{timestamp}.csv");
|
var appointmentsEntry = archive.CreateEntry($"appointments_{timestamp}.csv");
|
||||||
@@ -1980,9 +1982,9 @@ public class ToolsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. Catalog
|
// 5. Catalog
|
||||||
var catalogCategories = await _unitOfWork.CatalogCategories.GetAllAsync();
|
var catalogCategories = await _unitOfWork.CatalogCategories.FindAsync(cc => cc.CompanyId == companyId.Value);
|
||||||
var catalogCategoryPaths = BuildCategoryPathMap(catalogCategories);
|
var catalogCategoryPaths = BuildCategoryPathMap(catalogCategories);
|
||||||
var catalog = await _unitOfWork.CatalogItems.GetAllAsync();
|
var catalog = await _unitOfWork.CatalogItems.FindAsync(ci => ci.CompanyId == companyId.Value);
|
||||||
var catalogCsv = GenerateCatalogCsv(catalog, catalogCategoryPaths);
|
var catalogCsv = GenerateCatalogCsv(catalog, catalogCategoryPaths);
|
||||||
var catalogEntry = archive.CreateEntry($"catalog_{timestamp}.csv");
|
var catalogEntry = archive.CreateEntry($"catalog_{timestamp}.csv");
|
||||||
using (var entryStream = catalogEntry.Open())
|
using (var entryStream = catalogEntry.Open())
|
||||||
@@ -1992,7 +1994,7 @@ public class ToolsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 6. Inventory
|
// 6. Inventory
|
||||||
var inventory = await _unitOfWork.InventoryItems.GetAllAsync();
|
var inventory = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId.Value);
|
||||||
var inventoryCsv = GenerateInventoryCsv(inventory);
|
var inventoryCsv = GenerateInventoryCsv(inventory);
|
||||||
var inventoryEntry = archive.CreateEntry($"inventory_{timestamp}.csv");
|
var inventoryEntry = archive.CreateEntry($"inventory_{timestamp}.csv");
|
||||||
using (var entryStream = inventoryEntry.Open())
|
using (var entryStream = inventoryEntry.Open())
|
||||||
@@ -2002,7 +2004,7 @@ public class ToolsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 7. Equipment
|
// 7. Equipment
|
||||||
var equipment = await _unitOfWork.Equipment.GetAllAsync();
|
var equipment = await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId.Value);
|
||||||
var equipmentCsv = GenerateEquipmentCsv(equipment);
|
var equipmentCsv = GenerateEquipmentCsv(equipment);
|
||||||
var equipmentEntry = archive.CreateEntry($"equipment_{timestamp}.csv");
|
var equipmentEntry = archive.CreateEntry($"equipment_{timestamp}.csv");
|
||||||
using (var entryStream = equipmentEntry.Open())
|
using (var entryStream = equipmentEntry.Open())
|
||||||
@@ -2012,7 +2014,7 @@ public class ToolsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 8. Maintenance
|
// 8. Maintenance
|
||||||
var maintenance = await _unitOfWork.MaintenanceRecords.GetAllAsync(false, m => m.Equipment);
|
var maintenance = await _unitOfWork.MaintenanceRecords.FindAsync(m => m.CompanyId == companyId.Value, false, m => m.Equipment);
|
||||||
var maintenanceCsv = GenerateMaintenanceCsv(maintenance);
|
var maintenanceCsv = GenerateMaintenanceCsv(maintenance);
|
||||||
var maintenanceEntry = archive.CreateEntry($"maintenance_{timestamp}.csv");
|
var maintenanceEntry = archive.CreateEntry($"maintenance_{timestamp}.csv");
|
||||||
using (var entryStream = maintenanceEntry.Open())
|
using (var entryStream = maintenanceEntry.Open())
|
||||||
@@ -2022,7 +2024,7 @@ public class ToolsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 9. Vendors
|
// 9. Vendors
|
||||||
var vendors = await _unitOfWork.Vendors.GetAllAsync();
|
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId.Value);
|
||||||
var vendorsCsv = GenerateVendorsCsv(vendors);
|
var vendorsCsv = GenerateVendorsCsv(vendors);
|
||||||
var vendorsEntry = archive.CreateEntry($"vendors_{timestamp}.csv");
|
var vendorsEntry = archive.CreateEntry($"vendors_{timestamp}.csv");
|
||||||
using (var entryStream = vendorsEntry.Open())
|
using (var entryStream = vendorsEntry.Open())
|
||||||
@@ -2032,7 +2034,7 @@ public class ToolsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 10. Prep Services
|
// 10. Prep Services
|
||||||
var prepServices = await _unitOfWork.PrepServices.GetAllAsync();
|
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.CompanyId == companyId.Value);
|
||||||
var prepServicesCsv = GeneratePrepServicesCsv(prepServices);
|
var prepServicesCsv = GeneratePrepServicesCsv(prepServices);
|
||||||
var prepServicesEntry = archive.CreateEntry($"prep_services_{timestamp}.csv");
|
var prepServicesEntry = archive.CreateEntry($"prep_services_{timestamp}.csv");
|
||||||
using (var entryStream = prepServicesEntry.Open())
|
using (var entryStream = prepServicesEntry.Open())
|
||||||
@@ -2042,7 +2044,7 @@ public class ToolsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 11. Invoices
|
// 11. Invoices
|
||||||
var invoices = await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Job);
|
var invoices = await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId.Value, false, i => i.Customer, i => i.Job);
|
||||||
var invoicesCsv = GenerateInvoicesCsv(invoices);
|
var invoicesCsv = GenerateInvoicesCsv(invoices);
|
||||||
var invoicesEntry = archive.CreateEntry($"invoices_{timestamp}.csv");
|
var invoicesEntry = archive.CreateEntry($"invoices_{timestamp}.csv");
|
||||||
using (var entryStream = invoicesEntry.Open())
|
using (var entryStream = invoicesEntry.Open())
|
||||||
@@ -2052,7 +2054,7 @@ public class ToolsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 12. Chart of Accounts
|
// 12. Chart of Accounts
|
||||||
var accounts = await _unitOfWork.Accounts.GetAllAsync();
|
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value);
|
||||||
var accountsCsv = GenerateChartOfAccountsCsv(accounts);
|
var accountsCsv = GenerateChartOfAccountsCsv(accounts);
|
||||||
var accountsEntry = archive.CreateEntry($"chart_of_accounts_{timestamp}.csv");
|
var accountsEntry = archive.CreateEntry($"chart_of_accounts_{timestamp}.csv");
|
||||||
using (var entryStream = accountsEntry.Open())
|
using (var entryStream = accountsEntry.Open())
|
||||||
@@ -2062,7 +2064,7 @@ public class ToolsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 13. Expenses
|
// 13. Expenses
|
||||||
var expenses = await _unitOfWork.Expenses.GetAllAsync(false, e => e.ExpenseAccount, e => e.PaymentAccount, e => e.Vendor, e => e.Job);
|
var expenses = await _unitOfWork.Expenses.FindAsync(e => e.CompanyId == companyId.Value, false, e => e.ExpenseAccount, e => e.PaymentAccount, e => e.Vendor, e => e.Job);
|
||||||
var expensesCsv = GenerateExpensesCsv(expenses);
|
var expensesCsv = GenerateExpensesCsv(expenses);
|
||||||
var expensesEntry = archive.CreateEntry($"expenses_{timestamp}.csv");
|
var expensesEntry = archive.CreateEntry($"expenses_{timestamp}.csv");
|
||||||
using (var entryStream = expensesEntry.Open())
|
using (var entryStream = expensesEntry.Open())
|
||||||
@@ -2072,7 +2074,7 @@ public class ToolsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 14. Payments
|
// 14. Payments
|
||||||
var payments = await _unitOfWork.Payments.GetAllAsync(false, p => p.Invoice);
|
var payments = await _unitOfWork.Payments.FindAsync(p => p.CompanyId == companyId.Value, false, p => p.Invoice);
|
||||||
var paymentsCsv = GeneratePaymentsCsv(payments);
|
var paymentsCsv = GeneratePaymentsCsv(payments);
|
||||||
var paymentsEntry = archive.CreateEntry($"payments_{timestamp}.csv");
|
var paymentsEntry = archive.CreateEntry($"payments_{timestamp}.csv");
|
||||||
using (var entryStream = paymentsEntry.Open())
|
using (var entryStream = paymentsEntry.Open())
|
||||||
@@ -2258,9 +2260,9 @@ public class ToolsController : Controller
|
|||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
|
|
||||||
var catalogCategories = await _unitOfWork.CatalogCategories.GetAllAsync();
|
var catalogCategories = await _unitOfWork.CatalogCategories.FindAsync(cc => cc.CompanyId == companyId.Value);
|
||||||
var catalogCategoryPaths = BuildCategoryPathMap(catalogCategories);
|
var catalogCategoryPaths = BuildCategoryPathMap(catalogCategories);
|
||||||
var catalogItems = await _unitOfWork.CatalogItems.GetAllAsync();
|
var catalogItems = await _unitOfWork.CatalogItems.FindAsync(ci => ci.CompanyId == companyId.Value);
|
||||||
var csv = GenerateCatalogCsv(catalogItems, catalogCategoryPaths);
|
var csv = GenerateCatalogCsv(catalogItems, catalogCategoryPaths);
|
||||||
var fileName = $"catalog_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
var fileName = $"catalog_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
||||||
|
|
||||||
@@ -2326,7 +2328,7 @@ public class ToolsController : Controller
|
|||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
|
|
||||||
var equipment = await _unitOfWork.Equipment.GetAllAsync();
|
var equipment = await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId.Value);
|
||||||
var csv = GenerateEquipmentCsv(equipment);
|
var csv = GenerateEquipmentCsv(equipment);
|
||||||
var fileName = $"equipment_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
var fileName = $"equipment_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
||||||
|
|
||||||
@@ -2407,13 +2409,13 @@ public class ToolsController : Controller
|
|||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load all lookup tables
|
// Load all lookup tables — scoped to this company
|
||||||
var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
|
var jobStatuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == companyId.Value);
|
||||||
var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
|
var jobPriorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId.Value);
|
||||||
var quoteStatuses = await _unitOfWork.QuoteStatusLookups.GetAllAsync();
|
var quoteStatuses = await _unitOfWork.QuoteStatusLookups.FindAsync(s => s.CompanyId == companyId.Value);
|
||||||
var inventoryCategories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
|
var inventoryCategories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId.Value);
|
||||||
var appointmentStatuses = await _unitOfWork.AppointmentStatusLookups.GetAllAsync();
|
var appointmentStatuses = await _unitOfWork.AppointmentStatusLookups.FindAsync(s => s.CompanyId == companyId.Value);
|
||||||
var appointmentTypes = await _unitOfWork.AppointmentTypeLookups.GetAllAsync();
|
var appointmentTypes = await _unitOfWork.AppointmentTypeLookups.FindAsync(t => t.CompanyId == companyId.Value);
|
||||||
|
|
||||||
var csv = GenerateCompanySettingsCsv(company, jobStatuses, jobPriorities,
|
var csv = GenerateCompanySettingsCsv(company, jobStatuses, jobPriorities,
|
||||||
quoteStatuses, inventoryCategories, appointmentStatuses, appointmentTypes);
|
quoteStatuses, inventoryCategories, appointmentStatuses, appointmentTypes);
|
||||||
@@ -4092,7 +4094,7 @@ public class ToolsController : Controller
|
|||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
|
|
||||||
var prepServices = await _unitOfWork.PrepServices.GetAllAsync();
|
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.CompanyId == companyId.Value);
|
||||||
var csv = GeneratePrepServicesCsv(prepServices);
|
var csv = GeneratePrepServicesCsv(prepServices);
|
||||||
var fileName = $"prep_services_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
var fileName = $"prep_services_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
||||||
|
|
||||||
@@ -4124,7 +4126,7 @@ public class ToolsController : Controller
|
|||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
|
|
||||||
var vendors = await _unitOfWork.Vendors.GetAllAsync();
|
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId.Value);
|
||||||
var csv = GenerateVendorsCsv(vendors);
|
var csv = GenerateVendorsCsv(vendors);
|
||||||
var fileName = $"vendors_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
var fileName = $"vendors_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
||||||
|
|
||||||
@@ -4156,7 +4158,7 @@ public class ToolsController : Controller
|
|||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
|
|
||||||
var accounts = await _unitOfWork.Accounts.GetAllAsync();
|
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value);
|
||||||
var csv = GenerateChartOfAccountsCsv(accounts);
|
var csv = GenerateChartOfAccountsCsv(accounts);
|
||||||
var fileName = $"chart_of_accounts_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
var fileName = $"chart_of_accounts_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
||||||
|
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ 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.
|
**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."
|
**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.
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
@@ -314,6 +314,8 @@ public static class HelpKnowledgeBase
|
|||||||
|
|
||||||
All QR codes require login — workers must have an active account. Logging in once on their phone is sufficient for the session.
|
All QR codes require login — workers must have an active account. Logging in once on their phone is sufficient for the session.
|
||||||
|
|
||||||
|
**Logging material usage from a PC (without QR scan):** On the Job Details page, expand the Materials Used section and click **Log Material**. A modal opens where you can: select any inventory item from a dropdown (current stock level shown), choose whether to enter the amount used or the amount remaining (the system calculates usage automatically), pick a reason (Job Usage or Waste/Spillage), and add optional notes. Saves immediately and updates inventory on hand.
|
||||||
|
|
||||||
**Blank Work Order:** Print a pre-formatted paper work order to hand to a walk-in customer before creating a digital job record.
|
**Blank Work Order:** Print a pre-formatted paper work order to hand to a walk-in customer before creating a digital job record.
|
||||||
- Access: Jobs list page → printer icon button "Blank Work Order" in the top-right toolbar. Or navigate directly to /WorkOrder/Blank.
|
- Access: Jobs list page → printer icon button "Blank Work Order" in the top-right toolbar. Or navigate directly to /WorkOrder/Blank.
|
||||||
- The PDF opens in a new tab ready to print. It includes: company logo and address, Drop Off Date field, Client Name / Client Phone / Due Date fields, 12-row parts table (Part Description / Color / Quote), Notes box, customizable Terms & Conditions text, and a Customer Signature line.
|
- The PDF opens in a new tab ready to print. It includes: company logo and address, Drop Off Date field, Client Name / Client Phone / Due Date fields, 12-row parts table (Part Description / Color / Quote), Notes box, customizable Terms & Conditions text, and a Customer Signature line.
|
||||||
|
|||||||
@@ -240,6 +240,7 @@ builder.Services.AddHostedService<AuditLogRetentionBackgroundService>();
|
|||||||
builder.Services.AddHostedService<StripeWebhookRetentionBackgroundService>();
|
builder.Services.AddHostedService<StripeWebhookRetentionBackgroundService>();
|
||||||
builder.Services.AddHostedService<SetupWizardReminderBackgroundService>();
|
builder.Services.AddHostedService<SetupWizardReminderBackgroundService>();
|
||||||
builder.Services.AddHostedService<RecurringTransactionService>();
|
builder.Services.AddHostedService<RecurringTransactionService>();
|
||||||
|
builder.Services.AddHostedService<AppointmentReminderBackgroundService>();
|
||||||
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
||||||
builder.Services.AddScoped<IStripeService, StripeService>();
|
builder.Services.AddScoped<IStripeService, StripeService>();
|
||||||
builder.Services.AddScoped<IStripeConnectService, StripeConnectService>();
|
builder.Services.AddScoped<IStripeConnectService, StripeConnectService>();
|
||||||
|
|||||||
@@ -52,12 +52,13 @@
|
|||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
@if (!Model.Items.Any())
|
@if (!Model.Items.Any())
|
||||||
{
|
{
|
||||||
|
var isCustomerListFiltered = !string.IsNullOrEmpty(ViewBag.SearchTerm as string);
|
||||||
<div class="text-center py-5">
|
<div class="text-center py-5">
|
||||||
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
|
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
|
||||||
<h5 class="mt-3 text-muted">No customers found</h5>
|
<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">
|
<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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -184,12 +185,13 @@
|
|||||||
<div class="mobile-card-view">
|
<div class="mobile-card-view">
|
||||||
@if (!Model.Items.Any())
|
@if (!Model.Items.Any())
|
||||||
{
|
{
|
||||||
|
var isMobileCustomerListFiltered = !string.IsNullOrEmpty(ViewBag.SearchTerm as string);
|
||||||
<div class="text-center py-5">
|
<div class="text-center py-5">
|
||||||
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
|
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
|
||||||
<h5 class="mt-3 text-muted">No customers found</h5>
|
<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">
|
<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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,12 +61,13 @@
|
|||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
@if (!Model.Items.Any())
|
@if (!Model.Items.Any())
|
||||||
{
|
{
|
||||||
|
var isEquipmentListFiltered = !string.IsNullOrEmpty(ViewBag.SearchTerm as string) || ViewBag.StatusFilter != null;
|
||||||
<div class="text-center py-5">
|
<div class="text-center py-5">
|
||||||
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
|
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
|
||||||
<h5 class="mt-3 text-muted">No equipment found</h5>
|
<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">
|
<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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,8 +48,9 @@
|
|||||||
<ol class="mb-3">
|
<ol class="mb-3">
|
||||||
<li class="mb-2">Open the job from <strong>Operations › Jobs</strong> and go to its Details page.</li>
|
<li class="mb-2">Open the job from <strong>Operations › Jobs</strong> and go to its Details page.</li>
|
||||||
<li class="mb-2">Scroll to the <strong>Invoice</strong> section near the bottom of the page.</li>
|
<li class="mb-2">Scroll to the <strong>Invoice</strong> section near the bottom of the page.</li>
|
||||||
<li class="mb-2">Click <strong>Create Invoice</strong>. The system generates an 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 pre-fills all line items, the discount, tax rate, payment terms, and due date from the job and customer automatically.</li>
|
||||||
<li class="mb-2">Review the invoice — check line items, totals, and the due date — then click <strong>Save Invoice</strong>.</li>
|
<li class="mb-2">Review the <strong>Totals</strong> panel on the right — if a discount was applied to the job it shows as a red <em>Discount Applied</em> line below the subtotal. Negative line items are allowed if you need to apply a manual credit or price adjustment.</li>
|
||||||
|
<li class="mb-2">Adjust anything you need, then click <strong>Save Invoice</strong>.</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<h3 class="h6 fw-semibold mt-3 mb-2">From the Invoices list (manual)</h3>
|
<h3 class="h6 fw-semibold mt-3 mb-2">From the Invoices list (manual)</h3>
|
||||||
@@ -139,7 +140,7 @@
|
|||||||
<li class="mb-2">Open the invoice from <strong>Operations › Invoices</strong> or from the job's Details page.</li>
|
<li class="mb-2">Open the invoice from <strong>Operations › Invoices</strong> or from the job's Details page.</li>
|
||||||
<li class="mb-2">Click <strong>Send Invoice</strong>. The status changes from Draft to Sent.</li>
|
<li class="mb-2">Click <strong>Send Invoice</strong>. The status changes from Draft to Sent.</li>
|
||||||
<li class="mb-2">If email notifications are configured, the customer receives an email with the invoice details and total due.</li>
|
<li class="mb-2">If email notifications are configured, the customer receives an email with the invoice details and total due.</li>
|
||||||
<li class="mb-2">A due date is set automatically based on the customer's payment terms (e.g., Net 30 means the due date is 30 days from today).</li>
|
<li class="mb-2">The due date and payment terms are pre-filled from the source quote (if the job came from a quote) or the customer’s payment terms — you can always override them before saving.</li>
|
||||||
</ol>
|
</ol>
|
||||||
<p>
|
<p>
|
||||||
You can also click <strong>Download PDF</strong> on any invoice to generate a print-ready PDF
|
You can also click <strong>Download PDF</strong> on any invoice to generate a print-ready PDF
|
||||||
|
|||||||
@@ -607,13 +607,28 @@
|
|||||||
no anonymous bumps.
|
no anonymous bumps.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3 class="h6 fw-semibold mt-3 mb-2"><i class="bi bi-box-seam me-1"></i>Bottom QR — Log Powder Usage</h3>
|
<h3 class="h6 fw-semibold mt-3 mb-2"><i class="bi bi-box-seam me-1"></i>Bottom QR — Log Powder Usage</h3>
|
||||||
<p>
|
<p>
|
||||||
One QR per unique powder on the job. Scanning opens the inventory usage log page pre-filled
|
One QR per unique powder on the job. Scanning opens the inventory usage log page pre-filled
|
||||||
with that powder and the job number, so you can record actual lbs used in seconds without
|
with that powder and the job number, so you can record actual lbs used in seconds without
|
||||||
navigating through the app.
|
navigating through the app.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<h3 class="h6 fw-semibold mt-3 mb-2"><i class="bi bi-droplet-half me-1"></i>Logging Material Usage from a PC</h3>
|
||||||
|
<p>
|
||||||
|
You don’t need a phone or QR code to log material usage. On the Job Details page, expand the
|
||||||
|
<strong>Materials Used</strong> section and click <strong>Log Material</strong>. A modal opens where you can:
|
||||||
|
</p>
|
||||||
|
<ul class="mb-2">
|
||||||
|
<li>Select any inventory item from a searchable dropdown — the item’s current stock level is shown when you pick it.</li>
|
||||||
|
<li>Choose <strong>Amount Used</strong> (enter how much was consumed) or <strong>Amount Remaining</strong> (enter what’s left in the bag — the system calculates the usage automatically).</li>
|
||||||
|
<li>Pick a reason: <em>Job Usage</em> or <em>Waste / Spillage</em>.</li>
|
||||||
|
<li>Add optional notes.</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Saving immediately reduces the item’s stock on hand and creates an entry in the Inventory Activity ledger, exactly like a QR scan would. The QR scan icon is still available next to the button for mobile workers.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
|
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
|
||||||
<i class="bi bi-lock flex-shrink-0 mt-1"></i>
|
<i class="bi bi-lock flex-shrink-0 mt-1"></i>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -191,12 +191,13 @@
|
|||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
@if (!Model.Items.Any())
|
@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">
|
<div class="text-center py-5">
|
||||||
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
|
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
|
||||||
<h5 class="mt-3 text-muted">No inventory items found</h5>
|
<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">
|
<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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -283,13 +283,13 @@
|
|||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<input type="number" name="InvoiceItems[@i].UnitPrice"
|
<input type="number" name="InvoiceItems[@i].UnitPrice"
|
||||||
class="form-control form-control-sm text-end unit-price-input"
|
class="form-control form-control-sm text-end unit-price-input"
|
||||||
value="@item.UnitPrice.ToString("F2")" min="0" step="0.01"
|
value="@item.UnitPrice.ToString("F2")" step="0.01"
|
||||||
onchange="recalcRow(this)" oninput="recalcRow(this)" />
|
onchange="recalcRow(this)" oninput="recalcRow(this)" />
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<input type="number" name="InvoiceItems[@i].TotalPrice"
|
<input type="number" name="InvoiceItems[@i].TotalPrice"
|
||||||
class="form-control form-control-sm text-end total-price-input"
|
class="form-control form-control-sm text-end total-price-input"
|
||||||
value="@item.TotalPrice.ToString("F2")" min="0" step="0.01"
|
value="@item.TotalPrice.ToString("F2")" step="0.01"
|
||||||
oninput="recalcTotals()" />
|
oninput="recalcTotals()" />
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
@@ -371,6 +371,10 @@
|
|||||||
<input asp-for="DiscountAmount" type="number" class="form-control form-control-sm text-end"
|
<input asp-for="DiscountAmount" type="number" class="form-control form-control-sm text-end"
|
||||||
min="0" step="0.01" oninput="recalcTotals()" />
|
min="0" step="0.01" oninput="recalcTotals()" />
|
||||||
</div>
|
</div>
|
||||||
|
<div id="discountRow" class="d-flex justify-content-between mb-1 d-none">
|
||||||
|
<span class="text-muted small">Discount Applied</span>
|
||||||
|
<span id="displayDiscount" class="small text-danger">−$0.00</span>
|
||||||
|
</div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
<label class="form-label mb-0 text-muted">Tax (%)</label>
|
<label class="form-label mb-0 text-muted">Tax (%)</label>
|
||||||
@@ -725,13 +729,13 @@
|
|||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<input type="number" name="InvoiceItems[${idx}].UnitPrice"
|
<input type="number" name="InvoiceItems[${idx}].UnitPrice"
|
||||||
class="form-control form-control-sm text-end unit-price-input"
|
class="form-control form-control-sm text-end unit-price-input"
|
||||||
value="${unitPrice.toFixed(2)}" min="0" step="0.01"
|
value="${unitPrice.toFixed(2)}" step="0.01"
|
||||||
onchange="recalcRow(this)" oninput="recalcRow(this)" />
|
onchange="recalcRow(this)" oninput="recalcRow(this)" />
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<input type="number" name="InvoiceItems[${idx}].TotalPrice"
|
<input type="number" name="InvoiceItems[${idx}].TotalPrice"
|
||||||
class="form-control form-control-sm text-end total-price-input"
|
class="form-control form-control-sm text-end total-price-input"
|
||||||
value="${total}" min="0" step="0.01"
|
value="${total}" step="0.01"
|
||||||
oninput="recalcTotals()" />
|
oninput="recalcTotals()" />
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
@@ -797,6 +801,15 @@
|
|||||||
const total = taxableAmount + tax;
|
const total = taxableAmount + tax;
|
||||||
|
|
||||||
document.getElementById('displaySubtotal').textContent = formatCurrency(subtotal);
|
document.getElementById('displaySubtotal').textContent = formatCurrency(subtotal);
|
||||||
|
const discountRow = document.getElementById('discountRow');
|
||||||
|
if (discountRow) {
|
||||||
|
if (discount > 0) {
|
||||||
|
document.getElementById('displayDiscount').textContent = '−' + formatCurrency(discount);
|
||||||
|
discountRow.classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
discountRow.classList.add('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
document.getElementById('displayTax').textContent = formatCurrency(tax);
|
document.getElementById('displayTax').textContent = formatCurrency(tax);
|
||||||
document.getElementById('displayTotal').textContent = formatCurrency(total);
|
document.getElementById('displayTotal').textContent = formatCurrency(total);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -869,42 +869,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (showSendModal)
|
}
|
||||||
{
|
|
||||||
<!-- Send Channel Choice Modal (shown when customer has both email + SMS) -->
|
@if (showSendModal)
|
||||||
<div class="modal fade" id="sendChannelModal" tabindex="-1" aria-labelledby="sendChannelModalLabel" aria-hidden="true">
|
{
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
<!-- Send Channel Choice Modal (shown when customer has both email + SMS available).
|
||||||
<div class="modal-content">
|
Lives outside the isDraft block so it also renders for Sent/Overdue invoices
|
||||||
<div class="modal-header border-0 pb-0">
|
where the customer's email was added after an SMS-only initial send. -->
|
||||||
<h5 class="modal-title" id="sendChannelModalLabel">
|
<div class="modal fade" id="sendChannelModal" tabindex="-1" aria-labelledby="sendChannelModalLabel" aria-hidden="true">
|
||||||
<i class="bi bi-send text-primary me-2"></i>Send Invoice
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
</h5>
|
<div class="modal-content">
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<div class="modal-header border-0 pb-0">
|
||||||
</div>
|
<h5 class="modal-title" id="sendChannelModalLabel">
|
||||||
<div class="modal-body pt-2">
|
<i class="bi bi-send text-primary me-2"></i>Send Invoice
|
||||||
<p class="mb-3">How would you like to send <strong>@Model.InvoiceNumber</strong> to <strong>@Model.CustomerName</strong>?</p>
|
</h5>
|
||||||
<div class="d-grid gap-2">
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
<button type="button" class="btn btn-outline-primary text-start" onclick="submitSendInvoice(true, false)" data-bs-dismiss="modal">
|
</div>
|
||||||
<i class="bi bi-envelope me-2"></i>Email only
|
<div class="modal-body pt-2">
|
||||||
<small class="d-block text-muted ms-4">PDF attached · @Model.CustomerEmail</small>
|
<p class="mb-3">How would you like to send <strong>@Model.InvoiceNumber</strong> to <strong>@Model.CustomerName</strong>?</p>
|
||||||
</button>
|
<div class="d-grid gap-2">
|
||||||
<button type="button" class="btn btn-outline-primary text-start" onclick="submitSendInvoice(false, true)" data-bs-dismiss="modal">
|
<button type="button" class="btn btn-outline-primary text-start" onclick="submitSendInvoice(true, false)" data-bs-dismiss="modal">
|
||||||
<i class="bi bi-phone me-2"></i>SMS only
|
<i class="bi bi-envelope me-2"></i>Email only
|
||||||
<small class="d-block text-muted ms-4">View link · @smsPhone</small>
|
<small class="d-block text-muted ms-4">PDF attached · @Model.CustomerEmail</small>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-primary text-start" onclick="submitSendInvoice(true, true)" data-bs-dismiss="modal">
|
<button type="button" class="btn btn-outline-primary text-start" onclick="submitSendInvoice(false, true)" data-bs-dismiss="modal">
|
||||||
<i class="bi bi-send me-2"></i>Both Email & SMS
|
<i class="bi bi-phone me-2"></i>SMS only
|
||||||
<small class="d-block text-muted ms-4">PDF via email + view link via SMS</small>
|
<small class="d-block text-muted ms-4">View link · @smsPhone</small>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<button type="button" class="btn btn-primary text-start" onclick="submitSendInvoice(true, true)" data-bs-dismiss="modal">
|
||||||
</div>
|
<i class="bi bi-send me-2"></i>Both Email & SMS
|
||||||
<div class="modal-footer border-0 pt-0">
|
<small class="d-block text-muted ms-4">PDF via email + view link via SMS</small>
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
}
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (canPay)
|
@if (canPay)
|
||||||
|
|||||||
@@ -221,6 +221,49 @@
|
|||||||
</div>
|
</div>
|
||||||
</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) -->
|
<!-- Pricing Options (Rush / Discount) -->
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@@ -370,7 +413,8 @@
|
|||||||
transferEfficiency = c.TransferEfficiency,
|
transferEfficiency = c.TransferEfficiency,
|
||||||
powderCostPerLb = c.PowderCostPerLb,
|
powderCostPerLb = c.PowderCostPerLb,
|
||||||
powderToOrder = c.PowderToOrder,
|
powderToOrder = c.PowderToOrder,
|
||||||
notes = c.Notes
|
notes = c.Notes,
|
||||||
|
noExtraLayerCharge = c.NoExtraLayerCharge
|
||||||
}),
|
}),
|
||||||
prepServices = item.PrepServices.Select(ps => new {
|
prepServices = item.PrepServices.Select(ps => new {
|
||||||
prepServiceId = ps.PrepServiceId,
|
prepServiceId = ps.PrepServiceId,
|
||||||
@@ -387,7 +431,7 @@
|
|||||||
"discountType": @Json.Serialize(Model.DiscountType),
|
"discountType": @Json.Serialize(Model.DiscountType),
|
||||||
"discountValue": @Model.DiscountValue,
|
"discountValue": @Model.DiscountValue,
|
||||||
"isRushJob": @Json.Serialize(Model.IsRushJob),
|
"isRushJob": @Json.Serialize(Model.IsRushJob),
|
||||||
"ovenCostId": null,
|
"ovenCostId": @Json.Serialize(Model.OvenCostId),
|
||||||
"areaUnit": @Json.Serialize((string?)ViewBag.AreaUnit),
|
"areaUnit": @Json.Serialize((string?)ViewBag.AreaUnit),
|
||||||
"useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)),
|
"useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)),
|
||||||
"pricingUrl": "@Url.Action("CalculatePricing", "Jobs")",
|
"pricingUrl": "@Url.Action("CalculatePricing", "Jobs")",
|
||||||
|
|||||||
@@ -653,7 +653,10 @@
|
|||||||
<span class="mobile-card-value">
|
<span class="mobile-card-value">
|
||||||
@foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
|
@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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1016,9 +1019,12 @@
|
|||||||
<span class="badge bg-primary rounded-pill ms-1">@materialsUsed.Count</span>
|
<span class="badge bg-primary rounded-pill ms-1">@materialsUsed.Count</span>
|
||||||
}
|
}
|
||||||
<i class="bi bi-chevron-down collapse-chevron ms-1" style="transition:transform .2s;"></i>
|
<i class="bi bi-chevron-down collapse-chevron ms-1" style="transition:transform .2s;"></i>
|
||||||
<span class="ms-auto">
|
<span class="ms-auto d-flex gap-2">
|
||||||
<a asp-controller="Inventory" asp-action="Scan" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation();">
|
<button type="button" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation(); openLogMaterialModal();">
|
||||||
<i class="bi bi-qr-code-scan me-1"></i>Log Material
|
<i class="bi bi-plus-circle me-1"></i>Log Material
|
||||||
|
</button>
|
||||||
|
<a asp-controller="Inventory" asp-action="Scan" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation();" title="Scan QR code">
|
||||||
|
<i class="bi bi-qr-code-scan"></i>
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1028,7 +1034,7 @@
|
|||||||
{
|
{
|
||||||
<div class="card-body text-muted text-center py-3 small">
|
<div class="card-body text-muted text-center py-3 small">
|
||||||
<i class="bi bi-droplet me-1"></i>No materials have been logged for this job yet.
|
<i class="bi bi-droplet me-1"></i>No materials have been logged for this job yet.
|
||||||
Use the QR label on an inventory item to log usage.
|
Click <strong>Log Material</strong> above or scan the QR label on an inventory item.
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -1089,6 +1095,78 @@
|
|||||||
</div><!-- /collapseMaterials -->
|
</div><!-- /collapseMaterials -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Log Material Modal -->
|
||||||
|
<div class="modal fade" id="logMaterialModal" tabindex="-1" aria-labelledby="logMaterialModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="logMaterialModalLabel">
|
||||||
|
<i class="bi bi-droplet-half me-2 text-primary"></i>Log Material Usage
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Inventory Item <span class="text-danger">*</span></label>
|
||||||
|
<div class="position-relative">
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" id="lmItemSearch"
|
||||||
|
placeholder="Search by name or manufacturer…" autocomplete="off"
|
||||||
|
oninput="lmComboInput()"
|
||||||
|
onfocus="lmComboOpen()"
|
||||||
|
onkeydown="lmComboKey(event)">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" tabindex="-1"
|
||||||
|
id="lmItemDropdownToggle" onclick="lmComboToggle()">
|
||||||
|
<i class="bi bi-chevron-down" style="font-size:.75rem;"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="lmItemDropdown"
|
||||||
|
style="display:none;max-height:220px;overflow-y:auto;z-index:1070;background:#fff;border:1px solid rgba(0,0,0,.15);border-radius:.375rem;box-shadow:0 4px 12px rgba(0,0,0,.12);">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="lmItemBalance" class="form-text text-muted d-none"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Entry Method</label>
|
||||||
|
<div class="d-flex gap-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="lmEntryMethod" id="lmMethodUsed" value="used" checked onchange="lmUpdateQuantityLabel()">
|
||||||
|
<label class="form-check-label" for="lmMethodUsed">Amount Used</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="lmEntryMethod" id="lmMethodRemaining" value="remaining" onchange="lmUpdateQuantityLabel()">
|
||||||
|
<label class="form-check-label" for="lmMethodRemaining">Amount Remaining</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label id="lmQtyLabel" class="form-label fw-semibold">Quantity Used <span class="text-danger">*</span></label>
|
||||||
|
<input type="number" id="lmQuantity" class="form-control" min="0" step="0.01" placeholder="0.00">
|
||||||
|
<div id="lmComputedUsed" class="form-text text-muted d-none"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Reason</label>
|
||||||
|
<select id="lmTransactionType" class="form-select">
|
||||||
|
<option value="JobUsage">Job Usage</option>
|
||||||
|
<option value="Waste">Waste / Spillage</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Notes</label>
|
||||||
|
<textarea id="lmNotes" class="form-control" rows="2" placeholder="Optional"></textarea>
|
||||||
|
</div>
|
||||||
|
<div id="lmAlert" class="alert alert-permanent d-none"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="lmSaveBtn" onclick="lmSave()">
|
||||||
|
<i class="bi bi-check-circle me-1"></i>Log Usage
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Part Intake Modal -->
|
<!-- Part Intake Modal -->
|
||||||
@{
|
@{
|
||||||
var intakeExpectedCount = Model.Items?.Sum(i => (int)i.Quantity) ?? 0;
|
var intakeExpectedCount = Model.Items?.Sum(i => (int)i.Quantity) ?? 0;
|
||||||
@@ -3082,6 +3160,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<!-- Log Material Modal JS -->
|
||||||
|
<script src="/js/log-material.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const inventoryItems = @Html.Raw(ViewBag.InventoryItemsForModal ?? "[]");
|
||||||
|
const jobPowderIds = @Html.Raw(ViewBag.JobPowderIds ?? "[]");
|
||||||
|
const jobId = @Model.Id;
|
||||||
|
const logUrl = '@Url.Action("LogMaterial", "Jobs")';
|
||||||
|
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||||
|
window.__logMaterial = { inventoryItems, jobPowderIds, jobId, logUrl, token };
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<!-- Save as Template Modal -->
|
<!-- Save as Template Modal -->
|
||||||
<div class="modal fade" id="saveTemplateModal" tabindex="-1" aria-labelledby="saveTemplateModalLabel" aria-hidden="true">
|
<div class="modal fade" id="saveTemplateModal" tabindex="-1" aria-labelledby="saveTemplateModalLabel" aria-hidden="true">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
|
|||||||
@@ -190,6 +190,49 @@
|
|||||||
</div>
|
</div>
|
||||||
</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) -->
|
<!-- Pricing Options (Rush / Discount) -->
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@@ -357,7 +400,8 @@
|
|||||||
transferEfficiency = c.TransferEfficiency,
|
transferEfficiency = c.TransferEfficiency,
|
||||||
powderCostPerLb = c.PowderCostPerLb,
|
powderCostPerLb = c.PowderCostPerLb,
|
||||||
powderToOrder = c.PowderToOrder,
|
powderToOrder = c.PowderToOrder,
|
||||||
notes = c.Notes
|
notes = c.Notes,
|
||||||
|
noExtraLayerCharge = c.NoExtraLayerCharge
|
||||||
}),
|
}),
|
||||||
prepServices = item.PrepServices.Select(ps => new {
|
prepServices = item.PrepServices.Select(ps => new {
|
||||||
prepServiceId = ps.PrepServiceId,
|
prepServiceId = ps.PrepServiceId,
|
||||||
@@ -374,7 +418,7 @@
|
|||||||
"discountType": @Json.Serialize(Model.DiscountType),
|
"discountType": @Json.Serialize(Model.DiscountType),
|
||||||
"discountValue": @Model.DiscountValue,
|
"discountValue": @Model.DiscountValue,
|
||||||
"isRushJob": @Json.Serialize(Model.IsRushJob),
|
"isRushJob": @Json.Serialize(Model.IsRushJob),
|
||||||
"ovenCostId": null,
|
"ovenCostId": @Json.Serialize(Model.OvenCostId),
|
||||||
"areaUnit": @Json.Serialize((string?)ViewBag.AreaUnit),
|
"areaUnit": @Json.Serialize((string?)ViewBag.AreaUnit),
|
||||||
"useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)),
|
"useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)),
|
||||||
"pricingUrl": "@Url.Action("CalculatePricing", "Jobs")",
|
"pricingUrl": "@Url.Action("CalculatePricing", "Jobs")",
|
||||||
|
|||||||
@@ -153,7 +153,8 @@
|
|||||||
transferEfficiency = c.TransferEfficiency,
|
transferEfficiency = c.TransferEfficiency,
|
||||||
powderCostPerLb = c.PowderCostPerLb,
|
powderCostPerLb = c.PowderCostPerLb,
|
||||||
powderToOrder = c.PowderToOrder,
|
powderToOrder = c.PowderToOrder,
|
||||||
notes = c.Notes
|
notes = c.Notes,
|
||||||
|
noExtraLayerCharge = c.NoExtraLayerCharge
|
||||||
}),
|
}),
|
||||||
prepServices = item.PrepServices.Select(ps => new {
|
prepServices = item.PrepServices.Select(ps => new {
|
||||||
prepServiceId = ps.PrepServiceId,
|
prepServiceId = ps.PrepServiceId,
|
||||||
|
|||||||
@@ -2,8 +2,21 @@
|
|||||||
@{
|
@{
|
||||||
var emailDefault = ViewBag.EmailDefaultOnComplete == true;
|
var emailDefault = ViewBag.EmailDefaultOnComplete == true;
|
||||||
var preLoggedPowder = ViewBag.PreLoggedPowder as Dictionary<int, decimal> ?? new Dictionary<int, decimal>();
|
var preLoggedPowder = ViewBag.PreLoggedPowder as Dictionary<int, decimal> ?? new Dictionary<int, decimal>();
|
||||||
// Track remaining credit per InventoryItemId as we allocate it across coat rows
|
|
||||||
var remainingCredit = preLoggedPowder.ToDictionary(kv => kv.Key, kv => kv.Value);
|
// Group all coats by inventory item so we ask once per powder color, not once per item/coat
|
||||||
|
var powderGroups = (Model.Items ?? new List<PowderCoating.Application.DTOs.Job.JobItemDto>())
|
||||||
|
.SelectMany(i => i.Coats ?? new List<PowderCoating.Application.DTOs.Job.JobItemCoatDto>())
|
||||||
|
.Where(c => c.InventoryItemId.HasValue)
|
||||||
|
.GroupBy(c => c.InventoryItemId!.Value)
|
||||||
|
.Select(g => new {
|
||||||
|
InventoryItemId = g.Key,
|
||||||
|
ColorName = g.First().ColorName,
|
||||||
|
ColorCode = g.First().ColorCode,
|
||||||
|
TotalEstimatedLbs = g.Sum(c => c.PowderToOrder ?? 0m),
|
||||||
|
PreLogged = preLoggedPowder.GetValueOrDefault(g.Key, 0m)
|
||||||
|
})
|
||||||
|
.OrderBy(g => g.ColorName)
|
||||||
|
.ToList();
|
||||||
}
|
}
|
||||||
<div class="modal fade" id="completeJobModal" tabindex="-1">
|
<div class="modal fade" id="completeJobModal" tabindex="-1">
|
||||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||||
@@ -27,102 +40,59 @@
|
|||||||
<div class="form-text">Enter the total time in hours (e.g., 2.5 for 2 hours 30 minutes)</div>
|
<div class="form-text">Enter the total time in hours (e.g., 2.5 for 2 hours 30 minutes)</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (Model.Items != null && Model.Items.Any())
|
@if (powderGroups.Any())
|
||||||
{
|
{
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<h6 class="fw-semibold mb-3">
|
<h6 class="fw-semibold mb-1">
|
||||||
<i class="bi bi-palette me-1 text-primary"></i>Actual Powder Usage
|
<i class="bi bi-palette me-1 text-primary"></i>Actual Powder Usage
|
||||||
</h6>
|
</h6>
|
||||||
|
<p class="text-muted small mb-3">Enter total lbs used per powder color for the entire job.</p>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-sm table-hover">
|
<table class="table table-sm table-hover">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Item</th>
|
<th>Color / Powder</th>
|
||||||
<th>Coat</th>
|
|
||||||
<th>Color</th>
|
|
||||||
<th class="text-end">Estimated (lbs)</th>
|
<th class="text-end">Estimated (lbs)</th>
|
||||||
<th>Actual (lbs)</th>
|
<th style="width:150px">Actual Used (lbs)</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@{
|
@for (int i = 0; i < powderGroups.Count; i++)
|
||||||
var coatIndex = 0;
|
|
||||||
}
|
|
||||||
@foreach (var item in Model.Items)
|
|
||||||
{
|
{
|
||||||
if (item.Coats != null && item.Coats.Any())
|
var pg = powderGroups[i];
|
||||||
{
|
<tr>
|
||||||
foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
|
<td>
|
||||||
{
|
<span class="fw-semibold">@pg.ColorName</span>
|
||||||
<tr>
|
@if (!string.IsNullOrEmpty(pg.ColorCode))
|
||||||
<td>
|
{
|
||||||
<small>@item.Description</small>
|
<small class="text-muted ms-1">(@pg.ColorCode)</small>
|
||||||
@if (item.Quantity > 1)
|
}
|
||||||
{
|
</td>
|
||||||
<span class="badge bg-secondary ms-1">×@item.Quantity</span>
|
<td class="text-end text-muted small align-middle">
|
||||||
}
|
@pg.TotalEstimatedLbs.ToString("0.##")
|
||||||
</td>
|
</td>
|
||||||
<td><span class="badge bg-secondary">@coat.CoatName</span></td>
|
<td>
|
||||||
<td>
|
<input type="hidden" name="PowderUsages[@i].InventoryItemId" value="@pg.InventoryItemId" />
|
||||||
@if (!string.IsNullOrEmpty(coat.ColorName))
|
<input type="number"
|
||||||
{
|
class="form-control form-control-sm"
|
||||||
<small>
|
name="PowderUsages[@i].ActualPowderUsedLbs"
|
||||||
@coat.ColorName
|
step="0.01" min="0" placeholder="0.00"
|
||||||
@if (!string.IsNullOrEmpty(coat.ColorCode))
|
value="@(pg.PreLogged > 0 ? pg.PreLogged.ToString("0.##") : "")">
|
||||||
{
|
@if (pg.PreLogged > 0)
|
||||||
<span class="text-muted">(@coat.ColorCode)</span>
|
{
|
||||||
}
|
<small class="text-success d-block mt-1">
|
||||||
</small>
|
<i class="bi bi-check-circle me-1"></i>@pg.PreLogged.ToString("0.##") lbs already logged
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<small class="text-muted">@((coat.PowderToOrder ?? 0).ToString("0.##"))</small>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
@{
|
|
||||||
decimal preFilledLbs = 0m;
|
|
||||||
if (coat.InventoryItemId.HasValue && remainingCredit.TryGetValue(coat.InventoryItemId.Value, out var availCredit) && availCredit > 0)
|
|
||||||
{
|
|
||||||
preFilledLbs = availCredit;
|
|
||||||
remainingCredit[coat.InventoryItemId.Value] = 0m;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
<input type="hidden" name="CoatUsages[@coatIndex].JobItemCoatId" value="@coat.Id" />
|
|
||||||
<input type="number"
|
|
||||||
class="form-control form-control-sm"
|
|
||||||
name="CoatUsages[@coatIndex].ActualPowderUsedLbs"
|
|
||||||
step="0.01" min="0" placeholder="0.00"
|
|
||||||
value="@(preFilledLbs > 0 ? preFilledLbs.ToString("0.##") : "")"
|
|
||||||
style="max-width: 120px;">
|
|
||||||
@if (preFilledLbs > 0)
|
|
||||||
{
|
|
||||||
<small class="text-success d-block mt-1">
|
|
||||||
<i class="bi bi-check-circle me-1"></i>Already logged — inventory adjusted
|
|
||||||
</small>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
coatIndex++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<tr class="table-secondary">
|
|
||||||
<td colspan="5">
|
|
||||||
<small class="text-muted fst-italic">
|
|
||||||
<i class="bi bi-info-circle me-1"></i>
|
|
||||||
@item.Description — No coat information available (legacy job item)
|
|
||||||
</small>
|
</small>
|
||||||
</td>
|
}
|
||||||
</tr>
|
</td>
|
||||||
}
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="alert alert-info alert-permanent mb-0">
|
<div class="alert alert-info alert-permanent mb-0">
|
||||||
<i class="bi bi-info-circle me-2"></i>
|
<i class="bi bi-info-circle me-2"></i>
|
||||||
<small>Pre-filled values were already logged via scan — inventory is already adjusted for those. You can edit the amount; only the difference will be applied to inventory.</small>
|
<small>Pre-filled values were already logged via scan — inventory is already adjusted for those. You can edit the amount; only the difference will be applied.</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -479,7 +479,8 @@
|
|||||||
transferEfficiency = c.TransferEfficiency,
|
transferEfficiency = c.TransferEfficiency,
|
||||||
powderCostPerLb = c.PowderCostPerLb,
|
powderCostPerLb = c.PowderCostPerLb,
|
||||||
powderToOrder = c.PowderToOrder,
|
powderToOrder = c.PowderToOrder,
|
||||||
notes = c.Notes
|
notes = c.Notes,
|
||||||
|
noExtraLayerCharge = c.NoExtraLayerCharge
|
||||||
})
|
})
|
||||||
})))
|
})))
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -525,7 +525,8 @@
|
|||||||
transferEfficiency = c.TransferEfficiency,
|
transferEfficiency = c.TransferEfficiency,
|
||||||
powderCostPerLb = c.PowderCostPerLb,
|
powderCostPerLb = c.PowderCostPerLb,
|
||||||
powderToOrder = c.PowderToOrder,
|
powderToOrder = c.PowderToOrder,
|
||||||
notes = c.Notes
|
notes = c.Notes,
|
||||||
|
noExtraLayerCharge = c.NoExtraLayerCharge
|
||||||
})
|
})
|
||||||
})))
|
})))
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -82,6 +82,21 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const ownerForm = hfc?.closest('form');
|
const ownerForm = hfc?.closest('form');
|
||||||
if (ownerForm) {
|
if (ownerForm) {
|
||||||
ownerForm.addEventListener('submit', writeHiddenFields, { capture: true });
|
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
|
// 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)
|
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>`
|
? ` <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">
|
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>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
@@ -3414,7 +3430,7 @@ function loadItemsFromTemplate(templateItems) {
|
|||||||
coverageSqFtPerLb: c.coverageSqFtPerLb || 30,
|
coverageSqFtPerLb: c.coverageSqFtPerLb || 30,
|
||||||
transferEfficiency: c.transferEfficiency || 65,
|
transferEfficiency: c.transferEfficiency || 65,
|
||||||
powderCostPerLb: c.powderCostPerLb || null,
|
powderCostPerLb: c.powderCostPerLb || null,
|
||||||
noExtraLayerCharge: false
|
noExtraLayerCharge: !!c.noExtraLayerCharge
|
||||||
})),
|
})),
|
||||||
prepServices: (ti.prepServices || []).map(p => ({
|
prepServices: (ti.prepServices || []).map(p => ({
|
||||||
prepServiceId: p.prepServiceId,
|
prepServiceId: p.prepServiceId,
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
/**
|
||||||
|
* Log Material Usage modal — job details page.
|
||||||
|
* Reads config from window.__logMaterial injected inline by the view.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
let _items = [];
|
||||||
|
let _jobPowderIds = new Set();
|
||||||
|
let _modal = null;
|
||||||
|
|
||||||
|
// ── Combobox state ────────────────────────────────────────────────────────
|
||||||
|
let _selectedItemId = 0;
|
||||||
|
|
||||||
|
function lmComboInput() {
|
||||||
|
const q = document.getElementById('lmItemSearch')?.value?.toLowerCase() || '';
|
||||||
|
lmComboRender(q);
|
||||||
|
lmComboShow();
|
||||||
|
_selectedItemId = 0;
|
||||||
|
document.getElementById('lmItemBalance').classList.add('d-none');
|
||||||
|
lmOnQtyInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
function lmComboOpen() {
|
||||||
|
const q = document.getElementById('lmItemSearch')?.value?.toLowerCase() || '';
|
||||||
|
lmComboRender(q);
|
||||||
|
lmComboShow();
|
||||||
|
}
|
||||||
|
|
||||||
|
function lmComboToggle() {
|
||||||
|
const dd = document.getElementById('lmItemDropdown');
|
||||||
|
if (!dd) return;
|
||||||
|
if (dd.style.display === 'none' || !dd.style.display) {
|
||||||
|
lmComboOpen();
|
||||||
|
document.getElementById('lmItemSearch')?.focus();
|
||||||
|
} else {
|
||||||
|
lmComboClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lmMakeRow(it) {
|
||||||
|
const display = (it.manufacturer ? escLm(it.manufacturer) + ' – ' : '') +
|
||||||
|
escLm(it.name) +
|
||||||
|
(it.unitOfMeasure ? ' <span class="text-muted" style="font-size:.82rem;">(' + escLm(it.unitOfMeasure) + ')</span>' : '');
|
||||||
|
const label = (it.manufacturer ? it.manufacturer + ' - ' : '') +
|
||||||
|
it.name +
|
||||||
|
(it.unitOfMeasure ? ' (' + it.unitOfMeasure + ')' : '');
|
||||||
|
return `<div class="lm-item-opt" style="padding:.35rem .75rem;font-size:.875rem;cursor:pointer;"
|
||||||
|
data-id="${it.id}"
|
||||||
|
data-qty="${it.quantityOnHand}"
|
||||||
|
data-uom="${escLm(it.unitOfMeasure || '')}"
|
||||||
|
data-label="${escLm(label)}"
|
||||||
|
onmousedown="event.preventDefault(); lmComboSelect(this)"
|
||||||
|
onmouseenter="this.style.background='#f0f4ff'"
|
||||||
|
onmouseleave="this.classList.contains('lm-active') ? null : this.style.background=''">
|
||||||
|
${display}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lmComboRender(query) {
|
||||||
|
const dd = document.getElementById('lmItemDropdown');
|
||||||
|
if (!dd) return;
|
||||||
|
const filtered = query
|
||||||
|
? _items.filter(it => it.name.toLowerCase().includes(query) ||
|
||||||
|
(it.manufacturer && it.manufacturer.toLowerCase().includes(query)) ||
|
||||||
|
(it.unitOfMeasure && it.unitOfMeasure.toLowerCase().includes(query)))
|
||||||
|
: _items;
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
dd.innerHTML = '<div class="px-3 py-2 text-muted small">No items match.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobItems = filtered.filter(it => _jobPowderIds.has(it.id));
|
||||||
|
const otherItems = filtered.filter(it => !_jobPowderIds.has(it.id));
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
if (jobItems.length > 0) {
|
||||||
|
html += '<div class="px-3 py-1 text-muted" style="font-size:.72rem;letter-spacing:.04em;text-transform:uppercase;background:#f8f9fa;border-bottom:1px solid #dee2e6;">This Job</div>';
|
||||||
|
html += jobItems.map(lmMakeRow).join('');
|
||||||
|
if (otherItems.length > 0) {
|
||||||
|
html += '<div style="height:1px;background:#dee2e6;margin:.25rem 0;"></div>';
|
||||||
|
html += '<div class="px-3 py-1 text-muted" style="font-size:.72rem;letter-spacing:.04em;text-transform:uppercase;background:#f8f9fa;border-bottom:1px solid #dee2e6;">All Inventory</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html += otherItems.map(lmMakeRow).join('');
|
||||||
|
dd.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lmComboShow() {
|
||||||
|
const dd = document.getElementById('lmItemDropdown');
|
||||||
|
const anchor = document.getElementById('lmItemSearch');
|
||||||
|
if (!dd || !anchor) return;
|
||||||
|
const rect = anchor.closest('.input-group').getBoundingClientRect();
|
||||||
|
dd.style.position = 'fixed';
|
||||||
|
dd.style.top = (rect.bottom + 2) + 'px';
|
||||||
|
dd.style.left = rect.left + 'px';
|
||||||
|
dd.style.width = rect.width + 'px';
|
||||||
|
dd.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function lmComboClose() {
|
||||||
|
const dd = document.getElementById('lmItemDropdown');
|
||||||
|
if (dd) dd.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.lmComboSelect = function (el) {
|
||||||
|
_selectedItemId = parseInt(el.dataset.id) || 0;
|
||||||
|
document.getElementById('lmItemSearch').value = el.dataset.label;
|
||||||
|
lmComboClose();
|
||||||
|
|
||||||
|
const qty = parseFloat(el.dataset.qty) || 0;
|
||||||
|
const uom = el.dataset.uom;
|
||||||
|
const balDiv = document.getElementById('lmItemBalance');
|
||||||
|
balDiv.textContent = 'Current stock: ' + qty.toFixed(2) + (uom ? ' ' + uom : '');
|
||||||
|
balDiv.classList.remove('d-none');
|
||||||
|
lmOnQtyInput();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.lmComboInput = lmComboInput;
|
||||||
|
window.lmComboOpen = lmComboOpen;
|
||||||
|
window.lmComboToggle = lmComboToggle;
|
||||||
|
|
||||||
|
window.lmComboKey = function (event) {
|
||||||
|
const dd = document.getElementById('lmItemDropdown');
|
||||||
|
if (!dd || dd.style.display === 'none') {
|
||||||
|
if (event.key === 'ArrowDown' || event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
lmComboOpen();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const opts = Array.from(dd.querySelectorAll('.lm-item-opt'));
|
||||||
|
let idx = opts.findIndex(o => o.classList.contains('lm-active'));
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
idx = Math.min(idx + 1, opts.length - 1);
|
||||||
|
opts.forEach(o => { o.classList.remove('lm-active'); o.style.background = ''; });
|
||||||
|
if (opts[idx]) { opts[idx].classList.add('lm-active'); opts[idx].style.background = '#e8eeff'; opts[idx].scrollIntoView({ block: 'nearest' }); }
|
||||||
|
} else if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
idx = Math.max(idx - 1, 0);
|
||||||
|
opts.forEach(o => { o.classList.remove('lm-active'); o.style.background = ''; });
|
||||||
|
if (opts[idx]) { opts[idx].classList.add('lm-active'); opts[idx].style.background = '#e8eeff'; opts[idx].scrollIntoView({ block: 'nearest' }); }
|
||||||
|
} else if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
const active = dd.querySelector('.lm-active') || opts[0];
|
||||||
|
if (active) active.dispatchEvent(new MouseEvent('mousedown'));
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
lmComboClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function escLm(s) {
|
||||||
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Quantity / label logic ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function lmOnQtyInput() {
|
||||||
|
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
|
||||||
|
if (method !== 'remaining') {
|
||||||
|
document.getElementById('lmComputedUsed').classList.add('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!_selectedItemId) {
|
||||||
|
document.getElementById('lmComputedUsed').classList.add('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const item = _items.find(it => it.id === _selectedItemId);
|
||||||
|
const onHand = item ? (parseFloat(item.quantityOnHand) || 0) : 0;
|
||||||
|
const remaining = parseFloat(document.getElementById('lmQuantity').value) || 0;
|
||||||
|
const used = onHand - remaining;
|
||||||
|
const computedDiv = document.getElementById('lmComputedUsed');
|
||||||
|
computedDiv.textContent = 'Usage = ' + onHand.toFixed(2) + ' − ' + remaining.toFixed(2) + ' = ' + used.toFixed(2) + (item?.unitOfMeasure ? ' ' + item.unitOfMeasure : '');
|
||||||
|
computedDiv.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.lmUpdateQuantityLabel = function () {
|
||||||
|
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
|
||||||
|
document.getElementById('lmQtyLabel').innerHTML =
|
||||||
|
(method === 'remaining' ? 'Quantity Remaining' : 'Quantity Used') +
|
||||||
|
' <span class="text-danger">*</span>';
|
||||||
|
lmOnQtyInput();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Modal open / save ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
window.openLogMaterialModal = function () {
|
||||||
|
_selectedItemId = 0;
|
||||||
|
document.getElementById('lmItemSearch').value = '';
|
||||||
|
document.getElementById('lmItemBalance').classList.add('d-none');
|
||||||
|
document.getElementById('lmQuantity').value = '';
|
||||||
|
document.getElementById('lmComputedUsed').classList.add('d-none');
|
||||||
|
document.getElementById('lmTransactionType').value = 'JobUsage';
|
||||||
|
document.getElementById('lmNotes').value = '';
|
||||||
|
document.getElementById('lmAlert').classList.add('d-none');
|
||||||
|
document.getElementById('lmSaveBtn').disabled = false;
|
||||||
|
document.getElementById('lmMethodUsed').checked = true;
|
||||||
|
window.lmUpdateQuantityLabel();
|
||||||
|
lmComboClose();
|
||||||
|
if (_modal) _modal.show();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.lmSave = async function () {
|
||||||
|
const cfg = window.__logMaterial;
|
||||||
|
const alertEl = document.getElementById('lmAlert');
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
alertEl.className = 'alert alert-danger alert-permanent';
|
||||||
|
alertEl.textContent = msg;
|
||||||
|
alertEl.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_selectedItemId) { showError('Please select an inventory item.'); return; }
|
||||||
|
|
||||||
|
const qtyInput = parseFloat(document.getElementById('lmQuantity').value) || 0;
|
||||||
|
if (qtyInput <= 0) { showError('Please enter a quantity greater than zero.'); return; }
|
||||||
|
|
||||||
|
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
|
||||||
|
let quantityUsed = qtyInput;
|
||||||
|
if (method === 'remaining') {
|
||||||
|
const item = _items.find(it => it.id === _selectedItemId);
|
||||||
|
const onHand = item ? (parseFloat(item.quantityOnHand) || 0) : 0;
|
||||||
|
quantityUsed = onHand - qtyInput;
|
||||||
|
if (quantityUsed <= 0) {
|
||||||
|
showError('Remaining quantity cannot be equal to or greater than the current stock (' + onHand.toFixed(2) + ').');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = document.getElementById('lmSaveBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
alertEl.classList.add('d-none');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(cfg.logUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'RequestVerificationToken': cfg.token
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
jobId: cfg.jobId,
|
||||||
|
inventoryItemId: _selectedItemId,
|
||||||
|
quantityUsed: quantityUsed,
|
||||||
|
transactionType: document.getElementById('lmTransactionType').value,
|
||||||
|
notes: document.getElementById('lmNotes').value.trim() || null
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success) {
|
||||||
|
if (_modal) _modal.hide();
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
showError(data.message || 'An error occurred.');
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showError('Network error. Please try again.');
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Init ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
const cfg = window.__logMaterial;
|
||||||
|
if (!cfg) return;
|
||||||
|
|
||||||
|
_items = cfg.inventoryItems || [];
|
||||||
|
_jobPowderIds = new Set(cfg.jobPowderIds || []);
|
||||||
|
_modal = new bootstrap.Modal(document.getElementById('logMaterialModal'));
|
||||||
|
|
||||||
|
document.getElementById('lmQuantity').addEventListener('input', lmOnQtyInput);
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (!e.target.closest('#lmItemSearch') &&
|
||||||
|
!e.target.closest('#lmItemDropdown') &&
|
||||||
|
!e.target.closest('#lmItemDropdownToggle')) {
|
||||||
|
lmComboClose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Moq;
|
||||||
|
using PowderCoating.Core.Entities;
|
||||||
|
using PowderCoating.Core.Enums;
|
||||||
|
using PowderCoating.Core.Interfaces;
|
||||||
|
using PowderCoating.Infrastructure.Data;
|
||||||
|
using PowderCoating.Infrastructure.Repositories;
|
||||||
|
using PowderCoating.Web.Controllers;
|
||||||
|
|
||||||
|
namespace PowderCoating.UnitTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that the explicit <c>CompanyId == companyId</c> predicates added to every
|
||||||
|
/// user-facing controller action actually prevent cross-tenant data leakage.
|
||||||
|
///
|
||||||
|
/// Each test seeds entities for TWO companies, creates a controller whose ITenantContext
|
||||||
|
/// returns Company 1's ID, calls the action, and asserts that Company 2's data never
|
||||||
|
/// appears in the result.
|
||||||
|
///
|
||||||
|
/// These tests validate the defense-in-depth layer (explicit predicates in controllers)
|
||||||
|
/// independently of the EF Core global query filters, which behave differently on the
|
||||||
|
/// in-memory provider when no HttpContext is present.
|
||||||
|
/// </summary>
|
||||||
|
public class MultiTenantIsolationTests
|
||||||
|
{
|
||||||
|
// ── Repository-level isolation ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// FindAsync with an explicit CompanyId predicate returns only the matching company's rows,
|
||||||
|
/// even when rows from other companies exist in the database.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Repository_FindAsync_WithCompanyIdPredicate_ExcludesOtherTenants()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
context.Customers.Add(MakeCustomer(id: 1, companyId: 1, firstName: "Alice"));
|
||||||
|
context.Customers.Add(MakeCustomer(id: 2, companyId: 2, firstName: "Bob"));
|
||||||
|
context.Customers.Add(MakeCustomer(id: 3, companyId: 1, firstName: "Carol"));
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var uow = new UnitOfWork(context);
|
||||||
|
var results = (await uow.Customers.FindAsync(c => c.CompanyId == 1)).ToList();
|
||||||
|
|
||||||
|
Assert.Equal(2, results.Count);
|
||||||
|
Assert.All(results, c => Assert.Equal(1, c.CompanyId));
|
||||||
|
Assert.DoesNotContain(results, c => c.ContactFirstName == "Bob");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Repository_FindAsync_ReturnsEmpty_WhenNoMatchingCompanyId()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
context.Customers.Add(MakeCustomer(id: 1, companyId: 2, firstName: "Bob"));
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var uow = new UnitOfWork(context);
|
||||||
|
var results = await uow.Customers.FindAsync(c => c.CompanyId == 1);
|
||||||
|
|
||||||
|
Assert.Empty(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SmsConsentAuditController ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SmsConsentAudit_Index_ReturnsOnlyCurrentCompanyCustomers()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
context.Customers.Add(MakeCustomer(id: 1, companyId: 1, firstName: "Alice"));
|
||||||
|
context.Customers.Add(MakeCustomer(id: 2, companyId: 2, firstName: "Bob")); // other company
|
||||||
|
context.Customers.Add(MakeCustomer(id: 3, companyId: 1, firstName: "Carol"));
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = new SmsConsentAuditController(
|
||||||
|
new UnitOfWork(context),
|
||||||
|
MockTenant(companyId: 1),
|
||||||
|
Mock.Of<ILogger<SmsConsentAuditController>>());
|
||||||
|
SetHttpContext(controller);
|
||||||
|
|
||||||
|
var result = await controller.Index();
|
||||||
|
|
||||||
|
var view = Assert.IsType<ViewResult>(result);
|
||||||
|
var vm = Assert.IsType<SmsConsentAuditViewModel>(view.Model);
|
||||||
|
Assert.Equal(2, vm.TotalCount);
|
||||||
|
Assert.DoesNotContain(vm.Rows, r => r.CustomerName.Contains("Bob"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SmsConsentAudit_ExportCsv_ContainsOnlyCurrentCompanyCustomers()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
context.Customers.Add(MakeCustomer(id: 1, companyId: 1, firstName: "Alice"));
|
||||||
|
context.Customers.Add(MakeCustomer(id: 2, companyId: 2, firstName: "Bob"));
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = new SmsConsentAuditController(
|
||||||
|
new UnitOfWork(context),
|
||||||
|
MockTenant(companyId: 1),
|
||||||
|
Mock.Of<ILogger<SmsConsentAuditController>>());
|
||||||
|
SetHttpContext(controller);
|
||||||
|
|
||||||
|
var result = await controller.ExportCsv();
|
||||||
|
|
||||||
|
var file = Assert.IsType<FileContentResult>(result);
|
||||||
|
var csv = System.Text.Encoding.UTF8.GetString(file.FileContents);
|
||||||
|
Assert.Contains("Alice", csv);
|
||||||
|
Assert.DoesNotContain("Bob", csv);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TaxRatesController ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TaxRates_Index_ReturnsOnlyCurrentCompanyRates()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
context.TaxRates.Add(MakeTaxRate(id: 1, companyId: 1, name: "State Tax"));
|
||||||
|
context.TaxRates.Add(MakeTaxRate(id: 2, companyId: 2, name: "Foreign Tax")); // other company
|
||||||
|
context.TaxRates.Add(MakeTaxRate(id: 3, companyId: 1, name: "Local Tax"));
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = new TaxRatesController(
|
||||||
|
new UnitOfWork(context),
|
||||||
|
MockTenant(companyId: 1),
|
||||||
|
Mock.Of<ILogger<TaxRatesController>>());
|
||||||
|
SetHttpContext(controller);
|
||||||
|
|
||||||
|
var result = await controller.Index();
|
||||||
|
|
||||||
|
var view = Assert.IsType<ViewResult>(result);
|
||||||
|
var rates = Assert.IsAssignableFrom<IEnumerable<TaxRate>>(view.Model).ToList();
|
||||||
|
Assert.Equal(2, rates.Count);
|
||||||
|
Assert.All(rates, r => Assert.Equal(1, r.CompanyId));
|
||||||
|
Assert.DoesNotContain(rates, r => r.Name == "Foreign Tax");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RecurringTemplatesController ──────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RecurringTemplates_Index_ReturnsOnlyCurrentCompanyTemplates()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
context.RecurringTemplates.Add(MakeRecurringTemplate(id: 1, companyId: 1, name: "Monthly Rent"));
|
||||||
|
context.RecurringTemplates.Add(MakeRecurringTemplate(id: 2, companyId: 2, name: "Other Tenant Bill")); // other company
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = new RecurringTemplatesController(
|
||||||
|
new UnitOfWork(context),
|
||||||
|
MockTenant(companyId: 1),
|
||||||
|
CreateUserManagerMock().Object,
|
||||||
|
Mock.Of<ILogger<RecurringTemplatesController>>());
|
||||||
|
SetHttpContext(controller);
|
||||||
|
|
||||||
|
var result = await controller.Index();
|
||||||
|
|
||||||
|
var view = Assert.IsType<ViewResult>(result);
|
||||||
|
var templates = Assert.IsAssignableFrom<IEnumerable<RecurringTemplate>>(view.Model).ToList();
|
||||||
|
Assert.Single(templates);
|
||||||
|
Assert.Equal("Monthly Rent", templates[0].Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JobTemplatesController ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task JobTemplates_Index_ReturnsOnlyCurrentCompanyTemplates()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
context.JobTemplates.Add(MakeJobTemplate(id: 1, companyId: 1, name: "Standard Wheel Coat"));
|
||||||
|
context.JobTemplates.Add(MakeJobTemplate(id: 2, companyId: 2, name: "Other Company Template")); // other company
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = new JobTemplatesController(
|
||||||
|
new UnitOfWork(context),
|
||||||
|
MockTenant(companyId: 1));
|
||||||
|
SetHttpContext(controller);
|
||||||
|
|
||||||
|
var result = await controller.Index();
|
||||||
|
|
||||||
|
var view = Assert.IsType<ViewResult>(result);
|
||||||
|
var templates = Assert.IsAssignableFrom<IEnumerable<JobTemplate>>(view.Model).ToList();
|
||||||
|
Assert.Single(templates);
|
||||||
|
Assert.Equal("Standard Wheel Coat", templates[0].Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cross-tenant write protection ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that the companyId-scoped FindAsync used for SMS export returns zero
|
||||||
|
/// rows for a company that has no customers, even when another company has many.
|
||||||
|
/// Guards against the "empty predicate returns all" regression.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task SmsConsentAudit_ExportCsv_IsEmpty_WhenCompanyHasNoCustomers()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
context.Customers.Add(MakeCustomer(id: 1, companyId: 2, firstName: "Other"));
|
||||||
|
context.Customers.Add(MakeCustomer(id: 2, companyId: 2, firstName: "Also Other"));
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = new SmsConsentAuditController(
|
||||||
|
new UnitOfWork(context),
|
||||||
|
MockTenant(companyId: 1), // Company 1 has no customers
|
||||||
|
Mock.Of<ILogger<SmsConsentAuditController>>());
|
||||||
|
SetHttpContext(controller);
|
||||||
|
|
||||||
|
var result = await controller.ExportCsv();
|
||||||
|
|
||||||
|
var file = Assert.IsType<FileContentResult>(result);
|
||||||
|
var csv = System.Text.Encoding.UTF8.GetString(file.FileContents);
|
||||||
|
// Only header row, no data rows
|
||||||
|
Assert.DoesNotContain("Other", csv);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static ApplicationDbContext CreateContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
return new ApplicationDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns a mock ITenantContext that always yields the given companyId.</summary>
|
||||||
|
private static ITenantContext MockTenant(int companyId)
|
||||||
|
{
|
||||||
|
var mock = new Mock<ITenantContext>();
|
||||||
|
mock.Setup(t => t.GetCurrentCompanyId()).Returns(companyId);
|
||||||
|
return mock.Object;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetHttpContext(Controller controller)
|
||||||
|
{
|
||||||
|
controller.ControllerContext = new ControllerContext
|
||||||
|
{
|
||||||
|
HttpContext = new DefaultHttpContext()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Customer MakeCustomer(int id, int companyId, string firstName) => new()
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
CompanyId = companyId,
|
||||||
|
ContactFirstName = firstName,
|
||||||
|
ContactLastName = "Test",
|
||||||
|
IsCommercial = false
|
||||||
|
};
|
||||||
|
|
||||||
|
private static TaxRate MakeTaxRate(int id, int companyId, string name) => new()
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
CompanyId = companyId,
|
||||||
|
Name = name,
|
||||||
|
Rate = 8.5m
|
||||||
|
};
|
||||||
|
|
||||||
|
private static RecurringTemplate MakeRecurringTemplate(int id, int companyId, string name) => new()
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
CompanyId = companyId,
|
||||||
|
Name = name,
|
||||||
|
TemplateType = RecurringTemplateType.Bill,
|
||||||
|
Frequency = RecurringFrequency.Monthly,
|
||||||
|
IntervalCount = 1,
|
||||||
|
NextFireDate = DateTime.Today,
|
||||||
|
IsActive = true
|
||||||
|
};
|
||||||
|
|
||||||
|
private static JobTemplate MakeJobTemplate(int id, int companyId, string name) => new()
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
CompanyId = companyId,
|
||||||
|
Name = name
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Mock<UserManager<ApplicationUser>> CreateUserManagerMock()
|
||||||
|
{
|
||||||
|
var store = new Mock<IUserStore<ApplicationUser>>();
|
||||||
|
return new Mock<UserManager<ApplicationUser>>(
|
||||||
|
store.Object, null!, null!, null!, null!, null!, null!, null!, null!);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user