Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a44133a63 | |||
| 3b5511a703 | |||
| 8df37ca760 | |||
| 7239f55308 | |||
| 09e077897b | |||
| 051c86810e | |||
| 6721de91e4 | |||
| 226a6237a6 | |||
| cf6acc125f | |||
| f467862877 | |||
| 7ad7d84016 | |||
| 75b0a8afe2 | |||
| 38748c2152 | |||
| 4ec55e7290 | |||
| 3eda91f170 | |||
| cefdf3e35c | |||
| f34ee749be | |||
| 357ef84001 | |||
| 7a1a697dc2 | |||
| 539c6c2559 |
@@ -478,6 +478,27 @@ All modules below are fully implemented with controllers, views, and migrations
|
|||||||
- In-stock inventory powder: charge for calculated usage only (surface area × lbs/sqft × unit cost)
|
- In-stock inventory powder: charge for calculated usage only (surface area × lbs/sqft × unit cost)
|
||||||
- Tax exempt customers (`Customer.IsTaxExempt`): `TaxPercent` defaults to 0 on quote and invoice create; customer dropdown marks exempt customers with ★
|
- Tax exempt customers (`Customer.IsTaxExempt`): `TaxPercent` defaults to 0 on quote and invoice create; customer dropdown marks exempt customers with ★
|
||||||
|
|
||||||
|
### Pricing Routing Flags — Must Stay In Sync Across All Three Layers
|
||||||
|
|
||||||
|
`PricingCalculationService.CalculateQuoteItemPriceAsync` routes each item to the correct pricing path using boolean flags. **These flags MUST exist identically on `QuoteItem`, `JobItem`, and `CreateQuoteItemDto`, AND be mapped in all three `JobItemAssemblyService.CreateJobItem` overloads.**
|
||||||
|
|
||||||
|
| Flag | Effect if missing on JobItem |
|
||||||
|
|------|------------------------------|
|
||||||
|
| `IsAiItem` | Job repriced as calculated item; oven cost double-charged on every save |
|
||||||
|
| `IsGenericItem` | ManualUnitPrice ignored; price recalculated from surface area |
|
||||||
|
| `IsLaborItem` | Item repriced at surface-area rate instead of hours × labor rate |
|
||||||
|
| `IsSalesItem` | ManualUnitPrice ignored; item repriced using coat/surface math |
|
||||||
|
|
||||||
|
**Checklist when adding a new pricing routing flag:**
|
||||||
|
1. Add the property to `QuoteItem` (Core/Entities)
|
||||||
|
2. Add the property to `JobItem` (Core/Entities)
|
||||||
|
3. Add it to `CreateQuoteItemDto` (Application/DTOs)
|
||||||
|
4. Add it to `JobItemSeed` (private class in JobItemAssemblyService)
|
||||||
|
5. Map it in all three `JobItemAssemblyService.CreateJobItem` overloads
|
||||||
|
6. Include it in every `existingItemsData` JSON block in job views (`Edit.cshtml`, `EditItems.cshtml`) and in all job controller actions that build `CreateQuoteItemDto` from a `JobItem`
|
||||||
|
7. Add a migration if the field is new on a persisted entity
|
||||||
|
8. The structural test `PricingRoutingFlags_ExistOnBothQuoteItemAndJobItem` in `JobItemAssemblyServiceTests` will fail until steps 1–3 are done — this is intentional
|
||||||
|
|
||||||
### Branding
|
### Branding
|
||||||
- Application name: **Powder Coating Logix**
|
- Application name: **Powder Coating Logix**
|
||||||
- PCL logo: `wwwroot/images/pcl-logo.png` — used in sidebar header (when no tenant logo), login/register pages, sidebar footer
|
- PCL logo: `wwwroot/images/pcl-logo.png` — used in sidebar header (when no tenant logo), login/register pages, sidebar footer
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ namespace PowderCoating.Application.DTOs.Company
|
|||||||
|
|
||||||
// Labor Rates
|
// Labor Rates
|
||||||
public decimal StandardLaborRate { get; set; }
|
public decimal StandardLaborRate { get; set; }
|
||||||
|
public decimal? LaborCostPerHour { get; set; }
|
||||||
public decimal AdditionalCoatLaborPercent { get; set; }
|
public decimal AdditionalCoatLaborPercent { get; set; }
|
||||||
|
|
||||||
// Equipment Operating Costs
|
// Equipment Operating Costs
|
||||||
@@ -185,6 +186,10 @@ namespace PowderCoating.Application.DTOs.Company
|
|||||||
[Display(Name = "Standard Labor Rate ($/hr)")]
|
[Display(Name = "Standard Labor Rate ($/hr)")]
|
||||||
public decimal StandardLaborRate { get; set; }
|
public decimal StandardLaborRate { get; set; }
|
||||||
|
|
||||||
|
[Range(0, 10000, ErrorMessage = "Labor cost rate must be between 0 and 10,000")]
|
||||||
|
[Display(Name = "Shop Labor Cost Rate ($/hr)")]
|
||||||
|
public decimal? LaborCostPerHour { get; set; }
|
||||||
|
|
||||||
[Range(0, 100, ErrorMessage = "Additional coat labor percent must be between 0 and 100")]
|
[Range(0, 100, ErrorMessage = "Additional coat labor percent must be between 0 and 100")]
|
||||||
[Display(Name = "Additional Coat Labor (%)")]
|
[Display(Name = "Additional Coat Labor (%)")]
|
||||||
public decimal AdditionalCoatLaborPercent { get; set; } = 30m;
|
public decimal AdditionalCoatLaborPercent { get; set; } = 30m;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public class GiftCertificateListDto
|
|||||||
public GiftCertificateStatus Status { get; set; }
|
public GiftCertificateStatus Status { get; set; }
|
||||||
public DateTime IssueDate { get; set; }
|
public DateTime IssueDate { get; set; }
|
||||||
public DateTime? ExpiryDate { get; set; }
|
public DateTime? ExpiryDate { get; set; }
|
||||||
|
public Guid? BatchId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class GiftCertificateDto : GiftCertificateListDto
|
public class GiftCertificateDto : GiftCertificateListDto
|
||||||
@@ -87,3 +88,27 @@ public class RedeemGiftCertificateDto
|
|||||||
[Range(0.01, 9999.99)]
|
[Range(0.01, 9999.99)]
|
||||||
public decimal Amount { get; set; }
|
public decimal Amount { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class BulkCreateGiftCertificateDto
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[Range(1, 500, ErrorMessage = "Quantity must be between 1 and 500.")]
|
||||||
|
[Display(Name = "Number of Certificates")]
|
||||||
|
public int Quantity { get; set; } = 25;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Range(1.00, 9999.99, ErrorMessage = "Amount must be between $1.00 and $9,999.99.")]
|
||||||
|
[Display(Name = "Face Value (each)")]
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Display(Name = "Issued Reason")]
|
||||||
|
public GiftCertificateIssuedReason IssuedReason { get; set; } = GiftCertificateIssuedReason.Promotional;
|
||||||
|
|
||||||
|
[Display(Name = "Expiry Date (optional)")]
|
||||||
|
public DateTime? ExpiryDate { get; set; }
|
||||||
|
|
||||||
|
[StringLength(1000)]
|
||||||
|
[Display(Name = "Event / Notes (applied to all certificates)")]
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
using CsvHelper.Configuration.Attributes;
|
|
||||||
|
|
||||||
namespace PowderCoating.Application.DTOs.Import;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// DTO for importing shop workers from CSV files.
|
|
||||||
/// Valid Role values: GeneralLabor, Sandblaster, Coater, Masker, QualityControl, OvenOperator, Supervisor, Maintenance
|
|
||||||
/// </summary>
|
|
||||||
public class ShopWorkerImportDto
|
|
||||||
{
|
|
||||||
[Name("Name")]
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[Name("Role")]
|
|
||||||
public string Role { get; set; } = "GeneralLabor";
|
|
||||||
|
|
||||||
[Name("Phone")]
|
|
||||||
public string? Phone { get; set; }
|
|
||||||
|
|
||||||
[Name("Email")]
|
|
||||||
public string? Email { get; set; }
|
|
||||||
|
|
||||||
[Name("IsActive")]
|
|
||||||
public bool? IsActive { get; set; }
|
|
||||||
|
|
||||||
[Name("Notes")]
|
|
||||||
public string? Notes { get; set; }
|
|
||||||
}
|
|
||||||
@@ -515,6 +515,9 @@ public class JobEditItemsViewModel
|
|||||||
public string JobNumber { get; set; } = string.Empty;
|
public string JobNumber { get; set; } = string.Empty;
|
||||||
public int? CustomerId { get; set; }
|
public int? CustomerId { get; set; }
|
||||||
public decimal TaxPercent { get; set; }
|
public decimal TaxPercent { get; set; }
|
||||||
|
public int? OvenCostId { get; set; }
|
||||||
|
public int OvenBatches { get; set; } = 1;
|
||||||
|
public int? OvenCycleMinutes { get; set; }
|
||||||
public List<CreateQuoteItemDto> JobItems { get; set; } = new();
|
public List<CreateQuoteItemDto> JobItems { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -604,6 +604,11 @@ public class QuotePricingBreakdownDto
|
|||||||
|
|
||||||
public decimal SubtotalBeforeDiscount { get; set; }
|
public decimal SubtotalBeforeDiscount { get; set; }
|
||||||
|
|
||||||
|
public decimal PricingTierDiscountAmount { get; set; }
|
||||||
|
public decimal PricingTierDiscountPercent { get; set; }
|
||||||
|
public decimal QuoteDiscountAmount { get; set; }
|
||||||
|
public decimal QuoteDiscountPercent { get; set; }
|
||||||
|
|
||||||
public decimal DiscountAmount { get; set; }
|
public decimal DiscountAmount { get; set; }
|
||||||
public decimal DiscountPercent { get; set; }
|
public decimal DiscountPercent { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using PowderCoating.Core.Enums;
|
|
||||||
|
|
||||||
namespace PowderCoating.Application.DTOs.ShopWorker;
|
|
||||||
|
|
||||||
public class CreateShopWorkerDto
|
|
||||||
{
|
|
||||||
[Required(ErrorMessage = "Worker name is required")]
|
|
||||||
[StringLength(100, ErrorMessage = "Name cannot exceed 100 characters")]
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[Required(ErrorMessage = "Role is required")]
|
|
||||||
public ShopWorkerRole Role { get; set; } = ShopWorkerRole.GeneralLabor;
|
|
||||||
|
|
||||||
[Phone(ErrorMessage = "Invalid phone number format")]
|
|
||||||
[StringLength(20, ErrorMessage = "Phone cannot exceed 20 characters")]
|
|
||||||
public string? Phone { get; set; }
|
|
||||||
|
|
||||||
[EmailAddress(ErrorMessage = "Invalid email address format")]
|
|
||||||
[StringLength(100, ErrorMessage = "Email cannot exceed 100 characters")]
|
|
||||||
public string? Email { get; set; }
|
|
||||||
|
|
||||||
public bool IsActive { get; set; } = true;
|
|
||||||
|
|
||||||
[StringLength(500, ErrorMessage = "Notes cannot exceed 500 characters")]
|
|
||||||
public string? Notes { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
using PowderCoating.Core.Enums;
|
|
||||||
|
|
||||||
namespace PowderCoating.Application.DTOs.ShopWorker;
|
|
||||||
|
|
||||||
public class ShopWorkerDto
|
|
||||||
{
|
|
||||||
public int Id { get; set; }
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
public ShopWorkerRole Role { get; set; }
|
|
||||||
public string? Phone { get; set; }
|
|
||||||
public string? Email { get; set; }
|
|
||||||
public bool IsActive { get; set; }
|
|
||||||
public string? Notes { get; set; }
|
|
||||||
public DateTime CreatedAt { get; set; }
|
|
||||||
public DateTime? UpdatedAt { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using PowderCoating.Core.Enums;
|
|
||||||
|
|
||||||
namespace PowderCoating.Application.DTOs.ShopWorker;
|
|
||||||
|
|
||||||
public class UpdateShopWorkerDto
|
|
||||||
{
|
|
||||||
public int Id { get; set; }
|
|
||||||
|
|
||||||
[Required(ErrorMessage = "Worker name is required")]
|
|
||||||
[StringLength(100, ErrorMessage = "Name cannot exceed 100 characters")]
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[Required(ErrorMessage = "Role is required")]
|
|
||||||
public ShopWorkerRole Role { get; set; }
|
|
||||||
|
|
||||||
[Phone(ErrorMessage = "Invalid phone number format")]
|
|
||||||
[StringLength(20, ErrorMessage = "Phone cannot exceed 20 characters")]
|
|
||||||
public string? Phone { get; set; }
|
|
||||||
|
|
||||||
[EmailAddress(ErrorMessage = "Invalid email address format")]
|
|
||||||
[StringLength(100, ErrorMessage = "Email cannot exceed 100 characters")]
|
|
||||||
public string? Email { get; set; }
|
|
||||||
|
|
||||||
public bool IsActive { get; set; }
|
|
||||||
|
|
||||||
[StringLength(500, ErrorMessage = "Notes cannot exceed 500 characters")]
|
|
||||||
public string? Notes { get; set; }
|
|
||||||
}
|
|
||||||
@@ -217,6 +217,10 @@ public class UpdateCompanyUserDto
|
|||||||
[Display(Name = "Active")]
|
[Display(Name = "Active")]
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
|
|
||||||
|
[Range(0, 10000, ErrorMessage = "Labor cost rate must be between 0 and 10,000")]
|
||||||
|
[Display(Name = "Labor Cost Rate ($/hr)")]
|
||||||
|
public decimal? LaborCostPerHour { get; set; }
|
||||||
|
|
||||||
[Required(ErrorMessage = "Hire date is required")]
|
[Required(ErrorMessage = "Hire date is required")]
|
||||||
[Display(Name = "Hire Date")]
|
[Display(Name = "Hire Date")]
|
||||||
public DateTime HireDate { get; set; }
|
public DateTime HireDate { get; set; }
|
||||||
|
|||||||
@@ -136,18 +136,7 @@ public interface ICsvImportService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Task<CsvImportResultDto> ImportVendorsAsync(Stream csvStream, int companyId);
|
Task<CsvImportResultDto> ImportVendorsAsync(Stream csvStream, int companyId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generate a CSV template file for shop worker imports.
|
|
||||||
/// </summary>
|
|
||||||
byte[] GenerateShopWorkerTemplate();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Import shop workers from a CSV stream.
|
|
||||||
/// Updates existing workers matched by Name; creates new ones otherwise.
|
|
||||||
/// </summary>
|
|
||||||
Task<CsvImportResultDto> ImportShopWorkersAsync(Stream csvStream, int companyId);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generate a CSV template file for prep service imports.
|
/// Generate a CSV template file for prep service imports.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
byte[] GeneratePrepServiceTemplate();
|
byte[] GeneratePrepServiceTemplate();
|
||||||
|
|||||||
@@ -51,4 +51,10 @@ public interface IPdfService
|
|||||||
byte[]? companyLogo,
|
byte[]? companyLogo,
|
||||||
string? companyLogoContentType,
|
string? companyLogoContentType,
|
||||||
CompanyInfoDto companyInfo);
|
CompanyInfoDto companyInfo);
|
||||||
|
|
||||||
|
Task<byte[]> GenerateBulkGiftCertificatePdfAsync(
|
||||||
|
IList<GiftCertificateDto> certs,
|
||||||
|
byte[]? companyLogo,
|
||||||
|
string? companyLogoContentType,
|
||||||
|
CompanyInfoDto companyInfo);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ public class JobProfile : Profile
|
|||||||
// JobTimeEntry → JobTimeEntryDto
|
// JobTimeEntry → JobTimeEntryDto
|
||||||
CreateMap<JobTimeEntry, JobTimeEntryDto>()
|
CreateMap<JobTimeEntry, JobTimeEntryDto>()
|
||||||
.ForMember(dest => dest.WorkerName, opt => opt.MapFrom(src =>
|
.ForMember(dest => dest.WorkerName, opt => opt.MapFrom(src =>
|
||||||
src.UserDisplayName ?? (src.Worker != null ? src.Worker.Name : string.Empty)));
|
src.UserDisplayName ?? string.Empty));
|
||||||
|
|
||||||
// CreateJobDto to Job
|
// CreateJobDto to Job
|
||||||
CreateMap<CreateJobDto, Job>()
|
CreateMap<CreateJobDto, Job>()
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
using AutoMapper;
|
|
||||||
using PowderCoating.Application.DTOs.ShopWorker;
|
|
||||||
using PowderCoating.Core.Entities;
|
|
||||||
|
|
||||||
namespace PowderCoating.Application.Mappings;
|
|
||||||
|
|
||||||
public class ShopWorkerProfile : Profile
|
|
||||||
{
|
|
||||||
public ShopWorkerProfile()
|
|
||||||
{
|
|
||||||
// Entity to DTO
|
|
||||||
CreateMap<ShopWorker, ShopWorkerDto>();
|
|
||||||
|
|
||||||
// DTO to Entity
|
|
||||||
CreateMap<CreateShopWorkerDto, ShopWorker>();
|
|
||||||
CreateMap<UpdateShopWorkerDto, ShopWorker>();
|
|
||||||
|
|
||||||
// Reverse mappings
|
|
||||||
CreateMap<ShopWorkerDto, ShopWorker>();
|
|
||||||
CreateMap<ShopWorker, CreateShopWorkerDto>();
|
|
||||||
CreateMap<ShopWorker, UpdateShopWorkerDto>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,12 +21,13 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
IsGenericItem = source.IsGenericItem,
|
IsGenericItem = source.IsGenericItem,
|
||||||
IsLaborItem = source.IsLaborItem,
|
IsLaborItem = source.IsLaborItem,
|
||||||
IsSalesItem = source.IsSalesItem,
|
IsSalesItem = source.IsSalesItem,
|
||||||
|
IsAiItem = source.IsAiItem,
|
||||||
Sku = source.Sku,
|
Sku = source.Sku,
|
||||||
ManualUnitPrice = source.ManualUnitPrice,
|
ManualUnitPrice = source.ManualUnitPrice,
|
||||||
PowderCostOverride = source.PowderCostOverride,
|
PowderCostOverride = source.PowderCostOverride,
|
||||||
UnitPrice = pricing.UnitPrice,
|
UnitPrice = pricing.UnitPrice,
|
||||||
TotalPrice = pricing.TotalPrice,
|
TotalPrice = pricing.TotalPrice,
|
||||||
LaborCost = pricing.TotalPrice * 0.4m,
|
LaborCost = pricing.LaborCost,
|
||||||
RequiresSandblasting = source.RequiresSandblasting,
|
RequiresSandblasting = source.RequiresSandblasting,
|
||||||
RequiresMasking = source.RequiresMasking,
|
RequiresMasking = source.RequiresMasking,
|
||||||
EstimatedMinutes = source.EstimatedMinutes,
|
EstimatedMinutes = source.EstimatedMinutes,
|
||||||
@@ -106,12 +107,13 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
IsGenericItem = source.IsGenericItem,
|
IsGenericItem = source.IsGenericItem,
|
||||||
IsLaborItem = source.IsLaborItem,
|
IsLaborItem = source.IsLaborItem,
|
||||||
IsSalesItem = source.IsSalesItem,
|
IsSalesItem = source.IsSalesItem,
|
||||||
|
IsAiItem = source.IsAiItem,
|
||||||
Sku = source.Sku,
|
Sku = source.Sku,
|
||||||
ManualUnitPrice = source.ManualUnitPrice,
|
ManualUnitPrice = source.ManualUnitPrice,
|
||||||
PowderCostOverride = source.PowderCostOverride,
|
PowderCostOverride = source.PowderCostOverride,
|
||||||
UnitPrice = source.UnitPrice,
|
UnitPrice = source.UnitPrice,
|
||||||
TotalPrice = source.TotalPrice,
|
TotalPrice = source.TotalPrice,
|
||||||
LaborCost = source.TotalPrice * 0.4m,
|
LaborCost = source.ItemLaborCost,
|
||||||
RequiresSandblasting = source.RequiresSandblasting,
|
RequiresSandblasting = source.RequiresSandblasting,
|
||||||
RequiresMasking = source.RequiresMasking,
|
RequiresMasking = source.RequiresMasking,
|
||||||
EstimatedMinutes = source.EstimatedMinutes,
|
EstimatedMinutes = source.EstimatedMinutes,
|
||||||
@@ -191,6 +193,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
IsGenericItem = source.IsGenericItem,
|
IsGenericItem = source.IsGenericItem,
|
||||||
IsLaborItem = source.IsLaborItem,
|
IsLaborItem = source.IsLaborItem,
|
||||||
IsSalesItem = source.IsSalesItem,
|
IsSalesItem = source.IsSalesItem,
|
||||||
|
IsAiItem = source.IsAiItem,
|
||||||
Sku = source.Sku,
|
Sku = source.Sku,
|
||||||
ManualUnitPrice = source.ManualUnitPrice,
|
ManualUnitPrice = source.ManualUnitPrice,
|
||||||
PowderCostOverride = source.PowderCostOverride,
|
PowderCostOverride = source.PowderCostOverride,
|
||||||
@@ -270,6 +273,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
IsGenericItem = seed.IsGenericItem,
|
IsGenericItem = seed.IsGenericItem,
|
||||||
IsLaborItem = seed.IsLaborItem,
|
IsLaborItem = seed.IsLaborItem,
|
||||||
IsSalesItem = seed.IsSalesItem,
|
IsSalesItem = seed.IsSalesItem,
|
||||||
|
IsAiItem = seed.IsAiItem,
|
||||||
Sku = seed.Sku,
|
Sku = seed.Sku,
|
||||||
ManualUnitPrice = seed.ManualUnitPrice,
|
ManualUnitPrice = seed.ManualUnitPrice,
|
||||||
PowderCostOverride = seed.PowderCostOverride,
|
PowderCostOverride = seed.PowderCostOverride,
|
||||||
@@ -364,6 +368,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
public bool IsGenericItem { get; init; }
|
public bool IsGenericItem { get; init; }
|
||||||
public bool IsLaborItem { get; init; }
|
public bool IsLaborItem { get; init; }
|
||||||
public bool IsSalesItem { get; init; }
|
public bool IsSalesItem { get; init; }
|
||||||
|
public bool IsAiItem { get; init; }
|
||||||
public string? Sku { get; init; }
|
public string? Sku { get; init; }
|
||||||
public decimal? ManualUnitPrice { get; init; }
|
public decimal? ManualUnitPrice { get; init; }
|
||||||
public decimal? PowderCostOverride { get; init; }
|
public decimal? PowderCostOverride { get; init; }
|
||||||
|
|||||||
@@ -1858,6 +1858,50 @@ public class PdfService : IPdfService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a multi-page PDF containing one gift certificate per page, all using the same
|
||||||
|
/// branded layout as the single-certificate download. Used for bulk print runs (car shows,
|
||||||
|
/// promotions) so staff can hand-cut and distribute a full batch from one print job.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<byte[]> GenerateBulkGiftCertificatePdfAsync(
|
||||||
|
IList<GiftCertificateDto> certs,
|
||||||
|
byte[]? companyLogo,
|
||||||
|
string? companyLogoContentType,
|
||||||
|
CompanyInfoDto companyInfo)
|
||||||
|
{
|
||||||
|
QuestPDF.Settings.License = LicenseType.Community;
|
||||||
|
const string accent = "#7c3aed";
|
||||||
|
const string gold = "#b45309";
|
||||||
|
|
||||||
|
return await Task.Run(() =>
|
||||||
|
{
|
||||||
|
var doc = Document.Create(container =>
|
||||||
|
{
|
||||||
|
foreach (var cert in certs)
|
||||||
|
{
|
||||||
|
container.Page(page =>
|
||||||
|
{
|
||||||
|
page.Size(PageSizes.Letter);
|
||||||
|
page.Margin(0.75f, Unit.Inch);
|
||||||
|
page.PageColor(Colors.White);
|
||||||
|
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
|
||||||
|
|
||||||
|
page.Content().Element(c => ComposeGiftCertificateContent(c, cert, companyInfo, companyLogo, accent, gold));
|
||||||
|
|
||||||
|
page.Footer().AlignCenter().Text(text =>
|
||||||
|
{
|
||||||
|
text.Span(companyInfo.CompanyName).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||||
|
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
|
||||||
|
text.Span($" · {FormatPhoneNumber(companyInfo.Phone)}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return doc.GeneratePdf();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Composes the gift certificate body with a decorative double-border frame (outer purple 3pt,
|
/// Composes the gift certificate body with a decorative double-border frame (outer purple 3pt,
|
||||||
/// inner gold 1pt) that gives the document a premium printed-certificate appearance. Inside the
|
/// inner gold 1pt) that gives the document a premium printed-certificate appearance. Inside the
|
||||||
|
|||||||
@@ -30,23 +30,30 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
ArgumentNullException.ThrowIfNull(quote);
|
ArgumentNullException.ThrowIfNull(quote);
|
||||||
ArgumentNullException.ThrowIfNull(pricingResult);
|
ArgumentNullException.ThrowIfNull(pricingResult);
|
||||||
|
|
||||||
quote.MaterialCosts = pricingResult.MaterialCosts;
|
quote.MaterialCosts = pricingResult.MaterialCosts;
|
||||||
quote.LaborCosts = pricingResult.LaborCosts;
|
quote.LaborCosts = pricingResult.LaborCosts;
|
||||||
quote.EquipmentCosts = pricingResult.EquipmentCosts;
|
quote.EquipmentCosts = pricingResult.EquipmentCosts;
|
||||||
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
|
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
|
||||||
quote.OvenBatchCost = pricingResult.OvenBatchCost;
|
quote.OvenBatchCost = pricingResult.OvenBatchCost;
|
||||||
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
|
quote.FacilityOverheadCost = pricingResult.FacilityOverheadCost;
|
||||||
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
|
quote.FacilityOverheadRatePerHour = pricingResult.FacilityOverheadRatePerHour;
|
||||||
quote.OverheadAmount = pricingResult.OverheadCosts;
|
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
|
||||||
quote.OverheadPercent = pricingResult.OverheadPercent;
|
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
|
||||||
quote.ProfitMargin = pricingResult.ProfitMargin;
|
quote.OverheadAmount = pricingResult.OverheadCosts;
|
||||||
quote.ProfitPercent = pricingResult.ProfitPercent;
|
quote.OverheadPercent = pricingResult.OverheadPercent;
|
||||||
quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
|
quote.ProfitMargin = pricingResult.ProfitMargin;
|
||||||
quote.DiscountPercent = pricingResult.DiscountPercent;
|
quote.ProfitPercent = pricingResult.ProfitPercent;
|
||||||
quote.DiscountAmount = pricingResult.DiscountAmount;
|
quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
|
||||||
quote.RushFee = pricingResult.RushFee;
|
quote.PricingTierDiscountAmount = pricingResult.PricingTierDiscountAmount;
|
||||||
quote.TaxAmount = pricingResult.TaxAmount;
|
quote.PricingTierDiscountPercent = pricingResult.PricingTierDiscountPercent;
|
||||||
quote.Total = pricingResult.Total;
|
quote.QuoteDiscountAmount = pricingResult.QuoteDiscountAmount;
|
||||||
|
quote.QuoteDiscountPercent = pricingResult.QuoteDiscountPercent;
|
||||||
|
quote.DiscountPercent = pricingResult.DiscountPercent;
|
||||||
|
quote.DiscountAmount = pricingResult.DiscountAmount;
|
||||||
|
quote.SubtotalAfterDiscount = pricingResult.SubtotalAfterDiscount;
|
||||||
|
quote.RushFee = pricingResult.RushFee;
|
||||||
|
quote.TaxAmount = pricingResult.TaxAmount;
|
||||||
|
quote.Total = pricingResult.Total;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync(
|
public async Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync(
|
||||||
|
|||||||
@@ -58,7 +58,14 @@ public class ApplicationUser : IdentityUser
|
|||||||
|
|
||||||
public string? SidebarColor { get; set; } = "ocean";
|
public string? SidebarColor { get; set; } = "ocean";
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-worker labor cost per hour used for job costing profit/margin calculations.
|
||||||
|
/// Overrides the company-level LaborCostPerHour when set.
|
||||||
|
/// Leave null to use the company default.
|
||||||
|
/// </summary>
|
||||||
|
public decimal? LaborCostPerHour { get; set; }
|
||||||
|
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime? UpdatedAt { get; set; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
public DateTime? LastLoginDate { get; set; }
|
public DateTime? LastLoginDate { get; set; }
|
||||||
|
|||||||
@@ -141,8 +141,7 @@ public class Company : BaseEntity
|
|||||||
public virtual ICollection<Quote> Quotes { get; set; } = new List<Quote>();
|
public virtual ICollection<Quote> Quotes { get; set; } = new List<Quote>();
|
||||||
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
|
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
|
||||||
public virtual ICollection<Vendor> Vendors { get; set; } = new List<Vendor>();
|
public virtual ICollection<Vendor> Vendors { get; set; } = new List<Vendor>();
|
||||||
public virtual ICollection<ShopWorker> ShopWorkers { get; set; } = new List<ShopWorker>();
|
public virtual ICollection<PricingTier> PricingTiers { get; set; } = new List<PricingTier>();
|
||||||
public virtual ICollection<PricingTier> PricingTiers { get; set; } = new List<PricingTier>();
|
|
||||||
public virtual CompanyOperatingCosts? OperatingCosts { get; set; }
|
public virtual CompanyOperatingCosts? OperatingCosts { get; set; }
|
||||||
public virtual CompanyPreferences? Preferences { get; set; }
|
public virtual CompanyPreferences? Preferences { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ namespace PowderCoating.Core.Entities
|
|||||||
[Range(0, 10000)]
|
[Range(0, 10000)]
|
||||||
public decimal StandardLaborRate { get; set; }
|
public decimal StandardLaborRate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Actual labor cost per hour (wages + burden) used exclusively for internal job costing and profit/margin display.
|
||||||
|
/// This is NOT the billing rate — it should reflect what you actually pay workers.
|
||||||
|
/// When null, the costing engine defaults to 20% of StandardLaborRate.
|
||||||
|
/// </summary>
|
||||||
|
[Range(0, 10000)]
|
||||||
|
public decimal? LaborCostPerHour { get; set; }
|
||||||
|
|
||||||
// Additional Coat Labor Percentage (percentage of base labor for each additional coat beyond the first)
|
// Additional Coat Labor Percentage (percentage of base labor for each additional coat beyond the first)
|
||||||
[Range(0, 100)]
|
[Range(0, 100)]
|
||||||
public decimal AdditionalCoatLaborPercent { get; set; } = 30m;
|
public decimal AdditionalCoatLaborPercent { get; set; } = 30m;
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ public class GiftCertificate : BaseEntity
|
|||||||
/// <summary>Set when this GC was sold via an invoice line item.</summary>
|
/// <summary>Set when this GC was sold via an invoice line item.</summary>
|
||||||
public int? SourceInvoiceItemId { get; set; }
|
public int? SourceInvoiceItemId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Groups all certificates created in a single bulk run. Null for individually issued certs.</summary>
|
||||||
|
public Guid? BatchId { get; set; }
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
public virtual Customer? RecipientCustomer { get; set; }
|
public virtual Customer? RecipientCustomer { get; set; }
|
||||||
public virtual Customer? PurchasingCustomer { get; set; }
|
public virtual Customer? PurchasingCustomer { get; set; }
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ public class Job : BaseEntity
|
|||||||
// Selected oven (carried over from quote; null = company default rate)
|
// Selected oven (carried over from quote; null = company default rate)
|
||||||
public int? OvenCostId { get; set; }
|
public int? OvenCostId { get; set; }
|
||||||
|
|
||||||
|
// Oven scheduling (carried over from quote)
|
||||||
|
public int OvenBatches { get; set; } = 1;
|
||||||
|
public int? OvenCycleMinutes { get; set; }
|
||||||
|
|
||||||
// Pricing
|
// Pricing
|
||||||
public decimal QuotedPrice { get; set; }
|
public decimal QuotedPrice { get; set; }
|
||||||
public decimal FinalPrice { get; set; }
|
public decimal FinalPrice { get; set; }
|
||||||
@@ -62,6 +66,10 @@ public class Job : BaseEntity
|
|||||||
// Used to detect when the quote was subsequently edited so the job details page can warn the user.
|
// Used to detect when the quote was subsequently edited so the job details page can warn the user.
|
||||||
public DateTime? QuoteSnapshotUpdatedAt { get; set; }
|
public DateTime? QuoteSnapshotUpdatedAt { get; set; }
|
||||||
|
|
||||||
|
// Pricing snapshot — serialized QuotePricingBreakdownDto stored at save time so Details displays
|
||||||
|
// the breakdown that was actually calculated, not a re-run against current operating costs.
|
||||||
|
public string? PricingBreakdownJson { get; set; }
|
||||||
|
|
||||||
// Rework tracking
|
// Rework tracking
|
||||||
public bool IsReworkJob { get; set; }
|
public bool IsReworkJob { get; set; }
|
||||||
public int? OriginalJobId { get; set; } // Set when this job was created as a rework
|
public int? OriginalJobId { get; set; } // Set when this job was created as a rework
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ public class JobItem : BaseEntity
|
|||||||
// Values: "Simple" | "Moderate" | "Complex" | "Extreme"
|
// Values: "Simple" | "Moderate" | "Complex" | "Extreme"
|
||||||
public string? Complexity { get; set; }
|
public string? Complexity { get; set; }
|
||||||
|
|
||||||
|
// True when this item originated from an AI Photo Quote — ManualUnitPrice is used as-is
|
||||||
|
// and oven cost is not double-charged (it was excluded from the AI estimate at quote level).
|
||||||
|
public bool IsAiItem { get; set; }
|
||||||
|
|
||||||
// AI-generated standardized tags (comma-separated, e.g. "automotive,tubular")
|
// AI-generated standardized tags (comma-separated, e.g. "automotive,tubular")
|
||||||
public string? AiTags { get; set; }
|
public string? AiTags { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ namespace PowderCoating.Core.Entities;
|
|||||||
public class JobTimeEntry : BaseEntity
|
public class JobTimeEntry : BaseEntity
|
||||||
{
|
{
|
||||||
public int JobId { get; set; }
|
public int JobId { get; set; }
|
||||||
public int? ShopWorkerId { get; set; } // legacy — kept for entries created before user migration
|
|
||||||
public string? UserId { get; set; } // FK to AspNetUsers
|
public string? UserId { get; set; } // FK to AspNetUsers
|
||||||
public string? UserDisplayName { get; set; } // snapshot of worker name at entry creation time
|
public string? UserDisplayName { get; set; } // snapshot of worker name at entry creation time
|
||||||
public DateTime WorkDate { get; set; }
|
public DateTime WorkDate { get; set; }
|
||||||
@@ -13,5 +12,4 @@ public class JobTimeEntry : BaseEntity
|
|||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
public virtual Job Job { get; set; } = null!;
|
public virtual Job Job { get; set; } = null!;
|
||||||
public virtual ShopWorker? Worker { get; set; } // nullable — only populated for legacy entries
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,26 +40,33 @@ public class Quote : BaseEntity
|
|||||||
public DateTime? ApprovedDate { get; set; }
|
public DateTime? ApprovedDate { get; set; }
|
||||||
|
|
||||||
// Pricing — all values are snapshots captured at save time and must not be recalculated on load
|
// Pricing — all values are snapshots captured at save time and must not be recalculated on load
|
||||||
public decimal MaterialCosts { get; set; } // Sum of powder/material costs across all items
|
public decimal MaterialCosts { get; set; } // Sum of powder/material costs across all items
|
||||||
public decimal LaborCosts { get; set; } // Sum of labor costs across all items
|
public decimal LaborCosts { get; set; } // Sum of labor costs across all items
|
||||||
public decimal EquipmentCosts { get; set; } // Sum of equipment costs across all items
|
public decimal EquipmentCosts { get; set; } // Sum of equipment costs across all items
|
||||||
public decimal ItemsSubtotal { get; set; } // Sum of item prices before any quote-level costs
|
public decimal ItemsSubtotal { get; set; } // Sum of item prices before any quote-level costs
|
||||||
public decimal OvenBatchCost { get; set; } // Oven batch charge applied at quote level
|
public decimal OvenBatchCost { get; set; } // Oven batch charge applied at quote level
|
||||||
public decimal ShopSuppliesAmount { get; set; } // Shop supplies dollar amount
|
public decimal FacilityOverheadCost { get; set; } // Rent + utilities apportioned by estimated job hours
|
||||||
public decimal ShopSuppliesPercent { get; set; } // Shop supplies percentage used
|
public decimal FacilityOverheadRatePerHour { get; set; }// Rate used for facility overhead ($/hr)
|
||||||
public decimal OverheadAmount { get; set; } // Overhead dollar amount
|
public decimal ShopSuppliesAmount { get; set; } // Shop supplies dollar amount
|
||||||
public decimal OverheadPercent { get; set; } // Overhead percentage used
|
public decimal ShopSuppliesPercent { get; set; } // Shop supplies percentage used
|
||||||
public decimal ProfitMargin { get; set; } // Profit margin dollar amount
|
public decimal OverheadAmount { get; set; } // Legacy overhead (now always 0; kept for migration safety)
|
||||||
public decimal ProfitPercent { get; set; } // Profit margin percentage used
|
public decimal OverheadPercent { get; set; } // Legacy overhead percent
|
||||||
public decimal SubTotal { get; set; } // SubtotalBeforeDiscount (items + oven + overhead + profit + shop supplies)
|
public decimal ProfitMargin { get; set; } // Profit margin dollar amount (0 — baked into item prices)
|
||||||
|
public decimal ProfitPercent { get; set; } // Markup % used (for display reference)
|
||||||
|
public decimal SubTotal { get; set; } // SubtotalBeforeDiscount (items + oven + facility overhead + shop supplies)
|
||||||
|
|
||||||
// Discount Information
|
// Discount Information
|
||||||
public DiscountType DiscountType { get; set; } = DiscountType.None;
|
public DiscountType DiscountType { get; set; } = DiscountType.None;
|
||||||
public decimal DiscountValue { get; set; } = 0; // Value entered by user (percentage or fixed amount)
|
public decimal DiscountValue { get; set; } = 0; // Value entered by user (percentage or fixed amount)
|
||||||
public decimal DiscountPercent { get; set; } // Calculated: actual percentage applied
|
public decimal PricingTierDiscountAmount { get; set; } // Discount from customer's pricing tier
|
||||||
public decimal DiscountAmount { get; set; } // Calculated: actual dollar amount deducted
|
public decimal PricingTierDiscountPercent { get; set; } // Tier discount percentage
|
||||||
public string? DiscountReason { get; set; } // Why discount was applied
|
public decimal QuoteDiscountAmount { get; set; } // Manual quote-level discount amount
|
||||||
|
public decimal QuoteDiscountPercent { get; set; } // Manual quote-level discount percentage
|
||||||
|
public decimal DiscountPercent { get; set; } // Combined: actual percentage applied
|
||||||
|
public decimal DiscountAmount { get; set; } // Combined: actual dollar amount deducted
|
||||||
|
public string? DiscountReason { get; set; } // Why discount was applied
|
||||||
public bool HideDiscountFromCustomer { get; set; } = false; // Show only total on PDFs/portal
|
public bool HideDiscountFromCustomer { get; set; } = false; // Show only total on PDFs/portal
|
||||||
|
public decimal SubtotalAfterDiscount { get; set; } // SubTotal minus all discounts, before rush/tax
|
||||||
|
|
||||||
public decimal TaxPercent { get; set; }
|
public decimal TaxPercent { get; set; }
|
||||||
public decimal TaxAmount { get; set; }
|
public decimal TaxAmount { get; set; }
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
using PowderCoating.Core.Enums;
|
|
||||||
|
|
||||||
namespace PowderCoating.Core.Entities;
|
|
||||||
|
|
||||||
public class ShopWorker : BaseEntity
|
|
||||||
{
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
public ShopWorkerRole Role { get; set; } = ShopWorkerRole.GeneralLabor;
|
|
||||||
public string? Phone { get; set; }
|
|
||||||
public string? Email { get; set; }
|
|
||||||
public bool IsActive { get; set; } = true;
|
|
||||||
public string? Notes { get; set; }
|
|
||||||
|
|
||||||
// Relationships
|
|
||||||
public virtual ICollection<Job> AssignedJobs { get; set; } = new List<Job>();
|
|
||||||
public virtual ICollection<MaintenanceRecord> AssignedMaintenanceTasks { get; set; } = new List<MaintenanceRecord>();
|
|
||||||
public virtual ICollection<JobTimeEntry> TimeEntries { get; set; } = new List<JobTimeEntry>();
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
using PowderCoating.Core.Enums;
|
|
||||||
|
|
||||||
namespace PowderCoating.Core.Entities;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Optional per-role labor cost rate for job costing / profitability calculations.
|
|
||||||
/// If no rate is set for a role, the company's StandardLaborRate is used as fallback.
|
|
||||||
/// </summary>
|
|
||||||
public class ShopWorkerRoleCost : BaseEntity
|
|
||||||
{
|
|
||||||
public ShopWorkerRole Role { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Cost (pay rate) per hour for this role — used in job costing, NOT billing.</summary>
|
|
||||||
public decimal HourlyRate { get; set; }
|
|
||||||
}
|
|
||||||
@@ -78,17 +78,6 @@ public enum EquipmentStatus
|
|||||||
Retired = 4
|
Retired = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum ShopWorkerRole
|
|
||||||
{
|
|
||||||
GeneralLabor = 0,
|
|
||||||
Sandblaster = 1,
|
|
||||||
Coater = 2,
|
|
||||||
Masker = 3,
|
|
||||||
QualityControl = 4,
|
|
||||||
OvenOperator = 5,
|
|
||||||
Supervisor = 6,
|
|
||||||
Maintenance = 7
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum JobPhotoType
|
public enum JobPhotoType
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -54,9 +54,7 @@ public interface IUnitOfWork : IDisposable
|
|||||||
IRepository<AppointmentStatusLookup> AppointmentStatusLookups { get; }
|
IRepository<AppointmentStatusLookup> AppointmentStatusLookups { get; }
|
||||||
IRepository<AppointmentTypeLookup> AppointmentTypeLookups { get; }
|
IRepository<AppointmentTypeLookup> AppointmentTypeLookups { get; }
|
||||||
IRepository<PrepService> PrepServices { get; }
|
IRepository<PrepService> PrepServices { get; }
|
||||||
IRepository<ShopWorker> ShopWorkers { get; }
|
IRepository<ReworkRecord> ReworkRecords { get; }
|
||||||
IRepository<ShopWorkerRoleCost> ShopWorkerRoleCosts { get; }
|
|
||||||
IRepository<ReworkRecord> ReworkRecords { get; }
|
|
||||||
IRepository<Refund> Refunds { get; }
|
IRepository<Refund> Refunds { get; }
|
||||||
IRepository<CreditMemo> CreditMemos { get; }
|
IRepository<CreditMemo> CreditMemos { get; }
|
||||||
IRepository<CreditMemoApplication> CreditMemoApplications { get; }
|
IRepository<CreditMemoApplication> CreditMemoApplications { get; }
|
||||||
|
|||||||
@@ -205,11 +205,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
public DbSet<MaintenanceRecord> MaintenanceRecords { get; set; }
|
public DbSet<MaintenanceRecord> MaintenanceRecords { get; set; }
|
||||||
/// <summary>Supplier/vendor records used by Purchasing and Accounts Payable; tenant-filtered with soft delete.</summary>
|
/// <summary>Supplier/vendor records used by Purchasing and Accounts Payable; tenant-filtered with soft delete.</summary>
|
||||||
public DbSet<Vendor> Vendors { get; set; }
|
public DbSet<Vendor> Vendors { get; set; }
|
||||||
/// <summary>Shop worker profiles with role assignments; tenant-filtered with soft delete.</summary>
|
/// <summary>Rework records tracking quality failures and remediation work against a job; tenant-filtered with soft delete.</summary>
|
||||||
public DbSet<ShopWorker> ShopWorkers { get; set; }
|
|
||||||
/// <summary>Per-role labour cost rates used in pricing calculations; unique index on (CompanyId, Role).</summary>
|
|
||||||
public DbSet<ShopWorkerRoleCost> ShopWorkerRoleCosts { get; set; }
|
|
||||||
/// <summary>Rework records tracking quality failures and remediation work against a job; tenant-filtered with soft delete.</summary>
|
|
||||||
public DbSet<ReworkRecord> ReworkRecords { get; set; }
|
public DbSet<ReworkRecord> ReworkRecords { get; set; }
|
||||||
/// <summary>Customer refund records; tenant-filtered with soft delete.</summary>
|
/// <summary>Customer refund records; tenant-filtered with soft delete.</summary>
|
||||||
public DbSet<Refund> Refunds { get; set; }
|
public DbSet<Refund> Refunds { get; set; }
|
||||||
@@ -530,11 +526,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
modelBuilder.Entity<JobStatusHistory>().HasQueryFilter(e =>
|
modelBuilder.Entity<JobStatusHistory>().HasQueryFilter(e =>
|
||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
modelBuilder.Entity<ShopWorker>().HasQueryFilter(e =>
|
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
|
||||||
modelBuilder.Entity<ShopWorkerRoleCost>().HasQueryFilter(e =>
|
|
||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
|
||||||
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
|
||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
modelBuilder.Entity<Refund>().HasQueryFilter(e =>
|
modelBuilder.Entity<Refund>().HasQueryFilter(e =>
|
||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
@@ -1314,12 +1306,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
.HasForeignKey(m => m.PerformedById)
|
.HasForeignKey(m => m.PerformedById)
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
// ShopWorker relationships
|
|
||||||
modelBuilder.Entity<ShopWorker>()
|
|
||||||
.HasOne<Company>()
|
|
||||||
.WithMany(c => c.ShopWorkers)
|
|
||||||
.HasForeignKey(e => e.CompanyId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
modelBuilder.Entity<Job>()
|
modelBuilder.Entity<Job>()
|
||||||
.HasOne(j => j.AssignedUser)
|
.HasOne(j => j.AssignedUser)
|
||||||
@@ -1393,10 +1380,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
modelBuilder.Entity<PricingTier>()
|
modelBuilder.Entity<PricingTier>()
|
||||||
.HasIndex(p => p.CompanyId);
|
.HasIndex(p => p.CompanyId);
|
||||||
|
|
||||||
modelBuilder.Entity<ShopWorker>()
|
modelBuilder.Entity<CatalogCategory>()
|
||||||
.HasIndex(w => w.CompanyId);
|
|
||||||
|
|
||||||
modelBuilder.Entity<CatalogCategory>()
|
|
||||||
.HasIndex(c => c.CompanyId);
|
.HasIndex(c => c.CompanyId);
|
||||||
|
|
||||||
modelBuilder.Entity<CatalogCategory>()
|
modelBuilder.Entity<CatalogCategory>()
|
||||||
@@ -1431,12 +1415,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasDatabaseName("IX_Jobs_CompanyId_JobNumber");
|
.HasDatabaseName("IX_Jobs_CompanyId_JobNumber");
|
||||||
|
|
||||||
modelBuilder.Entity<ShopWorkerRoleCost>()
|
modelBuilder.Entity<Job>()
|
||||||
.HasIndex(r => new { r.CompanyId, r.Role })
|
|
||||||
.IsUnique()
|
|
||||||
.HasDatabaseName("IX_ShopWorkerRoleCosts_CompanyId_Role");
|
|
||||||
|
|
||||||
modelBuilder.Entity<Job>()
|
|
||||||
.Property(j => j.ShopAccessCode)
|
.Property(j => j.ShopAccessCode)
|
||||||
.HasDefaultValueSql("NEWID()");
|
.HasDefaultValueSql("NEWID()");
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ public class AuditInterceptor : SaveChangesInterceptor
|
|||||||
private static readonly HashSet<string> AuditedTypes = new(StringComparer.Ordinal)
|
private static readonly HashSet<string> AuditedTypes = new(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
nameof(Customer), nameof(Job), nameof(Quote), nameof(Equipment),
|
nameof(Customer), nameof(Job), nameof(Quote), nameof(Equipment),
|
||||||
nameof(MaintenanceRecord), nameof(Vendor), nameof(ShopWorker),
|
nameof(MaintenanceRecord), nameof(Vendor),
|
||||||
nameof(InventoryItem), nameof(Company),
|
nameof(InventoryItem), nameof(Company),
|
||||||
// Financial entities
|
// Financial entities
|
||||||
nameof(Invoice), nameof(Payment), nameof(Bill), nameof(BillPayment),
|
nameof(Invoice), nameof(Payment), nameof(Bill), nameof(BillPayment),
|
||||||
|
|||||||
Generated
+10748
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddJobOvenBatchFields : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "OvenBatches",
|
||||||
|
table: "Jobs",
|
||||||
|
type: "int",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "OvenCycleMinutes",
|
||||||
|
table: "Jobs",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6420));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6425));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6426));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "OvenBatches",
|
||||||
|
table: "Jobs");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "OvenCycleMinutes",
|
||||||
|
table: "Jobs");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2349));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2366));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2367));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+10751
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddJobItemIsAiItem : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsAiItem",
|
||||||
|
table: "JobItems",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7475));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7481));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7482));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsAiItem",
|
||||||
|
table: "JobItems");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6420));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6425));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6426));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10754
File diff suppressed because it is too large
Load Diff
+71
@@ -0,0 +1,71 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddGiftCertificateBatchId : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "BatchId",
|
||||||
|
table: "GiftCertificates",
|
||||||
|
type: "uniqueidentifier",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 0, 30, 26, 297, DateTimeKind.Utc).AddTicks(7656));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 0, 30, 26, 297, DateTimeKind.Utc).AddTicks(7662));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 0, 30, 26, 297, DateTimeKind.Utc).AddTicks(7664));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "BatchId",
|
||||||
|
table: "GiftCertificates");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7475));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7481));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7482));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10757
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddJobPricingSnapshot : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "PricingBreakdownJson",
|
||||||
|
table: "Jobs",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4618));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4623));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4625));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PricingBreakdownJson",
|
||||||
|
table: "Jobs");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2464));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2473));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2474));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
src/PowderCoating.Infrastructure/Migrations/20260515194344_AddQuotePricingSnapshotFields.Designer.cs
Generated
+10778
File diff suppressed because it is too large
Load Diff
+138
@@ -0,0 +1,138 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddQuotePricingSnapshotFields : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "FacilityOverheadCost",
|
||||||
|
table: "Quotes",
|
||||||
|
type: "decimal(18,2)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "FacilityOverheadRatePerHour",
|
||||||
|
table: "Quotes",
|
||||||
|
type: "decimal(18,2)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "PricingTierDiscountAmount",
|
||||||
|
table: "Quotes",
|
||||||
|
type: "decimal(18,2)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "PricingTierDiscountPercent",
|
||||||
|
table: "Quotes",
|
||||||
|
type: "decimal(18,2)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "QuoteDiscountAmount",
|
||||||
|
table: "Quotes",
|
||||||
|
type: "decimal(18,2)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "QuoteDiscountPercent",
|
||||||
|
table: "Quotes",
|
||||||
|
type: "decimal(18,2)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "SubtotalAfterDiscount",
|
||||||
|
table: "Quotes",
|
||||||
|
type: "decimal(18,2)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(845));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(850));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(852));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "FacilityOverheadCost",
|
||||||
|
table: "Quotes");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "FacilityOverheadRatePerHour",
|
||||||
|
table: "Quotes");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PricingTierDiscountAmount",
|
||||||
|
table: "Quotes");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PricingTierDiscountPercent",
|
||||||
|
table: "Quotes");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "QuoteDiscountAmount",
|
||||||
|
table: "Quotes");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "QuoteDiscountPercent",
|
||||||
|
table: "Quotes");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SubtotalAfterDiscount",
|
||||||
|
table: "Quotes");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4618));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4623));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4625));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10784
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddLaborCostPerHour : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "LaborCostPerHour",
|
||||||
|
table: "CompanyOperatingCosts",
|
||||||
|
type: "decimal(18,2)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "LaborCostPerHour",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "decimal(18,2)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LaborCostPerHour",
|
||||||
|
table: "CompanyOperatingCosts");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LaborCostPerHour",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(845));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(850));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(852));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -556,6 +556,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<bool>("IsBanned")
|
b.Property<bool>("IsBanned")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<decimal?>("LaborCostPerHour")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<DateTime?>("LastLoginDate")
|
b.Property<DateTime?>("LastLoginDate")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
@@ -2075,6 +2078,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<bool>("IsDeleted")
|
b.Property<bool>("IsDeleted")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<decimal?>("LaborCostPerHour")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<int>("MonthlyBillableHours")
|
b.Property<int>("MonthlyBillableHours")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -3290,6 +3296,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<Guid?>("BatchId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
b.Property<string>("CertificateCode")
|
b.Property<string>("CertificateCode")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(450)");
|
.HasColumnType("nvarchar(450)");
|
||||||
@@ -4205,9 +4214,18 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<decimal>("OvenBatchCost")
|
b.Property<decimal>("OvenBatchCost")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<int>("OvenBatches")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<int?>("OvenCostId")
|
b.Property<int?>("OvenCostId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int?>("OvenCycleMinutes")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("PricingBreakdownJson")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<int?>("QuoteId")
|
b.Property<int?>("QuoteId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -4476,6 +4494,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<bool>("IncludePrepCost")
|
b.Property<bool>("IncludePrepCost")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAiItem")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<bool>("IsDeleted")
|
b.Property<bool>("IsDeleted")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
@@ -6699,7 +6720,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2349),
|
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3131),
|
||||||
Description = "Standard pricing for regular customers",
|
Description = "Standard pricing for regular customers",
|
||||||
DiscountPercent = 0m,
|
DiscountPercent = 0m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6710,7 +6731,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2366),
|
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3137),
|
||||||
Description = "5% discount for preferred customers",
|
Description = "5% discount for preferred customers",
|
||||||
DiscountPercent = 5m,
|
DiscountPercent = 5m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6721,7 +6742,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2367),
|
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3138),
|
||||||
Description = "10% discount for premium customers",
|
Description = "10% discount for premium customers",
|
||||||
DiscountPercent = 10m,
|
DiscountPercent = 10m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6968,6 +6989,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<DateTime?>("ExpirationDate")
|
b.Property<DateTime?>("ExpirationDate")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<decimal>("FacilityOverheadCost")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<decimal>("FacilityOverheadRatePerHour")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<bool>("HideDiscountFromCustomer")
|
b.Property<bool>("HideDiscountFromCustomer")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
@@ -7013,6 +7040,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("PreparedById")
|
b.Property<string>("PreparedById")
|
||||||
.HasColumnType("nvarchar(450)");
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<decimal>("PricingTierDiscountAmount")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<decimal>("PricingTierDiscountPercent")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<decimal>("ProfitMargin")
|
b.Property<decimal>("ProfitMargin")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
@@ -7052,6 +7085,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<DateTime>("QuoteDate")
|
b.Property<DateTime>("QuoteDate")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<decimal>("QuoteDiscountAmount")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<decimal>("QuoteDiscountPercent")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<string>("QuoteNumber")
|
b.Property<string>("QuoteNumber")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(450)");
|
.HasColumnType("nvarchar(450)");
|
||||||
@@ -7077,6 +7116,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<decimal>("SubTotal")
|
b.Property<decimal>("SubTotal")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<decimal>("SubtotalAfterDiscount")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<string>("Tags")
|
b.Property<string>("Tags")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
|||||||
@@ -171,7 +171,6 @@ public class JobRepository : Repository<Job>, IJobRepository
|
|||||||
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
|
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
|
||||||
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
|
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
|
||||||
.Include(j => j.TimeEntries.Where(t => !t.IsDeleted))
|
.Include(j => j.TimeEntries.Where(t => !t.IsDeleted))
|
||||||
.ThenInclude(t => t.Worker)
|
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
private IRepository<AppointmentStatusLookup>? _appointmentStatusLookups;
|
private IRepository<AppointmentStatusLookup>? _appointmentStatusLookups;
|
||||||
private IRepository<AppointmentTypeLookup>? _appointmentTypeLookups;
|
private IRepository<AppointmentTypeLookup>? _appointmentTypeLookups;
|
||||||
private IRepository<PrepService>? _prepServices;
|
private IRepository<PrepService>? _prepServices;
|
||||||
private IRepository<ShopWorker>? _shopWorkers;
|
|
||||||
|
|
||||||
// Appointments
|
// Appointments
|
||||||
private IRepository<Appointment>? _appointments;
|
private IRepository<Appointment>? _appointments;
|
||||||
@@ -350,16 +349,7 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
public IRepository<PrepService> PrepServices =>
|
public IRepository<PrepService> PrepServices =>
|
||||||
_prepServices ??= new Repository<PrepService>(_context);
|
_prepServices ??= new Repository<PrepService>(_context);
|
||||||
|
|
||||||
/// <summary>Repository for <see cref="ShopWorker"/> profiles with role assignments; tenant-filtered with soft delete.</summary>
|
/// <summary>Repository for <see cref="ReworkRecord"/> quality-failure and remediation records; tenant-filtered with soft delete.</summary>
|
||||||
public IRepository<ShopWorker> ShopWorkers =>
|
|
||||||
_shopWorkers ??= new Repository<ShopWorker>(_context);
|
|
||||||
|
|
||||||
/// <summary>Repository for <see cref="ShopWorkerRoleCost"/> per-role labour cost rates; unique on (CompanyId, Role).</summary>
|
|
||||||
private IRepository<ShopWorkerRoleCost>? _shopWorkerRoleCosts;
|
|
||||||
public IRepository<ShopWorkerRoleCost> ShopWorkerRoleCosts =>
|
|
||||||
_shopWorkerRoleCosts ??= new Repository<ShopWorkerRoleCost>(_context);
|
|
||||||
|
|
||||||
/// <summary>Repository for <see cref="ReworkRecord"/> quality-failure and remediation records; tenant-filtered with soft delete.</summary>
|
|
||||||
private IRepository<ReworkRecord>? _reworkRecords;
|
private IRepository<ReworkRecord>? _reworkRecords;
|
||||||
public IRepository<ReworkRecord> ReworkRecords =>
|
public IRepository<ReworkRecord> ReworkRecords =>
|
||||||
_reworkRecords ??= new Repository<ReworkRecord>(_context);
|
_reworkRecords ??= new Repository<ReworkRecord>(_context);
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ public class CompanyDataPurgeService : ICompanyDataPurgeService
|
|||||||
await _context.NotificationTemplates.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
await _context.NotificationTemplates.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
await _context.Announcements.Where(x => x.TargetCompanyId == companyId).ExecuteDeleteAsync();
|
await _context.Announcements.Where(x => x.TargetCompanyId == companyId).ExecuteDeleteAsync();
|
||||||
await _context.BugReports.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
await _context.BugReports.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
await _context.ShopWorkers.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
|
||||||
await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
|
|
||||||
// ── Tier 4: Company configs and lookup tables ─────────────────────────
|
// ── Tier 4: Company configs and lookup tables ─────────────────────────
|
||||||
@@ -137,7 +136,6 @@ public class CompanyDataPurgeService : ICompanyDataPurgeService
|
|||||||
await _context.PurchaseOrderItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
await _context.PurchaseOrderItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
await _context.AiItemPredictions.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
await _context.AiItemPredictions.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
await _context.PowderUsageLogs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
await _context.PowderUsageLogs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
await _context.ShopWorkerRoleCosts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
|
||||||
await _context.OvenBatches.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
await _context.OvenBatches.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
await _context.Refunds.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
await _context.Refunds.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
await _context.CreditMemos.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
await _context.CreditMemos.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
@@ -160,7 +158,6 @@ public class CompanyDataPurgeService : ICompanyDataPurgeService
|
|||||||
await _context.OvenCosts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
await _context.OvenCosts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
await _context.Accounts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
await _context.Accounts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
await _context.NotificationLogs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
await _context.NotificationLogs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
await _context.ShopWorkers.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
|
||||||
await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
await _context.CatalogCategories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
await _context.CatalogCategories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
await _context.InventoryCategoryLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
await _context.InventoryCategoryLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using CsvHelper;
|
using CsvHelper;
|
||||||
using CsvHelper.Configuration;
|
using CsvHelper.Configuration;
|
||||||
@@ -2164,168 +2164,6 @@ public class CsvImportService : ICsvImportService
|
|||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Shop Worker Import
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates a downloadable CSV template with two example shop worker rows covering different roles.
|
|
||||||
/// Two rows help users see how Role values (Coater, Sandblaster, etc.) are expressed and remind
|
|
||||||
/// them that Role is optional — the importer will default to GeneralLabor when it is omitted.
|
|
||||||
/// </summary>
|
|
||||||
public byte[] GenerateShopWorkerTemplate()
|
|
||||||
{
|
|
||||||
using var memoryStream = new MemoryStream();
|
|
||||||
using var writer = new StreamWriter(memoryStream);
|
|
||||||
using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture));
|
|
||||||
|
|
||||||
csv.WriteHeader<ShopWorkerImportDto>();
|
|
||||||
csv.NextRecord();
|
|
||||||
|
|
||||||
csv.WriteRecord(new ShopWorkerImportDto
|
|
||||||
{
|
|
||||||
Name = "John Doe",
|
|
||||||
Role = "Coater",
|
|
||||||
Phone = "555-1234",
|
|
||||||
Email = "johndoe@example.com",
|
|
||||||
IsActive = true,
|
|
||||||
Notes = "Experienced powder coater"
|
|
||||||
});
|
|
||||||
csv.NextRecord();
|
|
||||||
|
|
||||||
csv.WriteRecord(new ShopWorkerImportDto
|
|
||||||
{
|
|
||||||
Name = "Jane Smith",
|
|
||||||
Role = "Sandblaster",
|
|
||||||
Phone = "555-5678",
|
|
||||||
Email = "janesmith@example.com",
|
|
||||||
IsActive = true,
|
|
||||||
Notes = ""
|
|
||||||
});
|
|
||||||
csv.NextRecord();
|
|
||||||
|
|
||||||
writer.Flush();
|
|
||||||
return memoryStream.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Imports shop workers from a CSV stream using an upsert strategy keyed on worker Name.
|
|
||||||
/// Like vendor import, this is intentionally an upsert rather than insert-only so that a
|
|
||||||
/// company can re-import their HR list to update phone/email/role details without worrying
|
|
||||||
/// about creating duplicates. Role is parsed case-insensitively with spaces stripped so that
|
|
||||||
/// "General Labor" and "GeneralLabor" are both accepted; an unrecognised role falls back to
|
|
||||||
/// GeneralLabor with a warning rather than failing the row.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="csvStream">Readable stream of CSV data (header row required).</param>
|
|
||||||
/// <param name="companyId">Tenant company that will own newly inserted worker records.</param>
|
|
||||||
public async Task<CsvImportResultDto> ImportShopWorkersAsync(Stream csvStream, int companyId)
|
|
||||||
{
|
|
||||||
var result = new CsvImportResultDto();
|
|
||||||
var rowNumber = 0;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var reader = new StreamReader(csvStream);
|
|
||||||
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
|
|
||||||
{
|
|
||||||
HeaderValidated = null,
|
|
||||||
MissingFieldFound = null
|
|
||||||
});
|
|
||||||
|
|
||||||
var records = csv.GetRecords<ShopWorkerImportDto>().ToList();
|
|
||||||
result.TotalRows = records.Count;
|
|
||||||
|
|
||||||
_logger.LogInformation("Starting import of {Count} shop workers for company {CompanyId}", records.Count, companyId);
|
|
||||||
|
|
||||||
// Load existing workers for upsert matching by name
|
|
||||||
var existingWorkers = await _unitOfWork.ShopWorkers.GetAllAsync();
|
|
||||||
var workerDict = existingWorkers
|
|
||||||
.Where(w => !string.IsNullOrEmpty(w.Name))
|
|
||||||
.GroupBy(w => w.Name.Trim().ToUpperInvariant())
|
|
||||||
.ToDictionary(g => g.Key, g => g.First());
|
|
||||||
|
|
||||||
foreach (var record in records)
|
|
||||||
{
|
|
||||||
rowNumber++;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(record.Name))
|
|
||||||
{
|
|
||||||
result.Errors.Add($"Row {rowNumber}: Name is required.");
|
|
||||||
result.ErrorCount++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse role
|
|
||||||
ShopWorkerRole role = ShopWorkerRole.GeneralLabor;
|
|
||||||
if (!string.IsNullOrEmpty(record.Role))
|
|
||||||
{
|
|
||||||
if (!Enum.TryParse<ShopWorkerRole>(record.Role.Replace(" ", ""), true, out role))
|
|
||||||
{
|
|
||||||
result.Warnings.Add($"Row {rowNumber}: Role '{record.Role}' not recognized. Valid values: GeneralLabor, Sandblaster, Coater, Masker, QualityControl, OvenOperator, Supervisor, Maintenance. Using 'GeneralLabor'.");
|
|
||||||
role = ShopWorkerRole.GeneralLabor;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var key = record.Name.Trim().ToUpperInvariant();
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
|
|
||||||
if (workerDict.TryGetValue(key, out var existing))
|
|
||||||
{
|
|
||||||
// Update
|
|
||||||
existing.Role = role;
|
|
||||||
existing.Phone = record.Phone ?? existing.Phone;
|
|
||||||
existing.Email = record.Email ?? existing.Email;
|
|
||||||
if (record.IsActive.HasValue) existing.IsActive = record.IsActive.Value;
|
|
||||||
existing.Notes = record.Notes ?? existing.Notes;
|
|
||||||
existing.UpdatedAt = now;
|
|
||||||
|
|
||||||
await _unitOfWork.CompleteAsync();
|
|
||||||
result.Warnings.Add($"Row {rowNumber}: Updated existing shop worker '{record.Name}'.");
|
|
||||||
result.SuccessCount++;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var worker = new Core.Entities.ShopWorker
|
|
||||||
{
|
|
||||||
CompanyId = companyId,
|
|
||||||
Name = record.Name.Trim(),
|
|
||||||
Role = role,
|
|
||||||
Phone = record.Phone,
|
|
||||||
Email = record.Email,
|
|
||||||
IsActive = record.IsActive ?? true,
|
|
||||||
Notes = record.Notes,
|
|
||||||
CreatedAt = now,
|
|
||||||
UpdatedAt = now
|
|
||||||
};
|
|
||||||
|
|
||||||
await _unitOfWork.ShopWorkers.AddAsync(worker);
|
|
||||||
await _unitOfWork.CompleteAsync();
|
|
||||||
result.SuccessCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
result.Errors.Add($"Row {rowNumber}: Database error - {ex.Message}");
|
|
||||||
result.ErrorCount++;
|
|
||||||
_logger.LogError(ex, "Error saving shop worker at row {RowNumber}", rowNumber);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Shop worker import completed: {SuccessCount} succeeded, {ErrorCount} failed", result.SuccessCount, result.ErrorCount);
|
|
||||||
result.Success = result.SuccessCount > 0;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
result.Errors.Add($"Fatal error: {ex.Message}");
|
|
||||||
result.Success = false;
|
|
||||||
_logger.LogError(ex, "Fatal error importing shop workers");
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Prep Service Import
|
#region Prep Service Import
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -133,7 +133,6 @@ public class AccountDataExportController : Controller
|
|||||||
case "Inventory": await AddInventorySheet(package, companyId, headerColor); break;
|
case "Inventory": await AddInventorySheet(package, companyId, headerColor); break;
|
||||||
case "Equipment": await AddEquipmentSheet(package, companyId, headerColor); break;
|
case "Equipment": await AddEquipmentSheet(package, companyId, headerColor); break;
|
||||||
case "Vendors": await AddVendorsSheet(package, companyId, headerColor); break;
|
case "Vendors": await AddVendorsSheet(package, companyId, headerColor); break;
|
||||||
case "ShopWorkers": await AddShopWorkersSheet(package, companyId, headerColor); break;
|
|
||||||
case "Users": await AddUsersSheet(package, companyId, headerColor); break;
|
case "Users": await AddUsersSheet(package, companyId, headerColor); break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,7 +181,6 @@ public class AccountDataExportController : Controller
|
|||||||
case "Inventory": WriteCsvEntry(zip, "Inventory.csv", await BuildInventoryCsv(companyId)); break;
|
case "Inventory": WriteCsvEntry(zip, "Inventory.csv", await BuildInventoryCsv(companyId)); break;
|
||||||
case "Equipment": WriteCsvEntry(zip, "Equipment.csv", await BuildEquipmentCsv(companyId)); break;
|
case "Equipment": WriteCsvEntry(zip, "Equipment.csv", await BuildEquipmentCsv(companyId)); break;
|
||||||
case "Vendors": WriteCsvEntry(zip, "Vendors.csv", await BuildVendorsCsv(companyId)); break;
|
case "Vendors": WriteCsvEntry(zip, "Vendors.csv", await BuildVendorsCsv(companyId)); break;
|
||||||
case "ShopWorkers": WriteCsvEntry(zip, "ShopWorkers.csv", await BuildShopWorkersCsv(companyId)); break;
|
|
||||||
case "Users": WriteCsvEntry(zip, "Users.csv", await BuildUsersCsv(companyId)); break;
|
case "Users": WriteCsvEntry(zip, "Users.csv", await BuildUsersCsv(companyId)); break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,12 +266,6 @@ public class AccountDataExportController : Controller
|
|||||||
.Where(s => s.CompanyId == companyId && !s.IsDeleted)
|
.Where(s => s.CompanyId == companyId && !s.IsDeleted)
|
||||||
.OrderBy(s => s.CompanyName).ToListAsync();
|
.OrderBy(s => s.CompanyName).ToListAsync();
|
||||||
|
|
||||||
/// <summary>Fetches all non-deleted shop workers for the company.</summary>
|
|
||||||
private Task<List<ShopWorker>> FetchShopWorkersAsync(int companyId) =>
|
|
||||||
_db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
|
|
||||||
.Where(w => w.CompanyId == companyId && !w.IsDeleted)
|
|
||||||
.OrderBy(w => w.Name).ToListAsync();
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fetches all users for the company. <c>IsDeleted</c> is intentionally omitted because
|
/// Fetches all users for the company. <c>IsDeleted</c> is intentionally omitted because
|
||||||
/// Identity users use <c>IsActive = false</c> for soft-deletion, not the base-entity flag.
|
/// Identity users use <c>IsActive = false</c> for soft-deletion, not the base-entity flag.
|
||||||
@@ -462,23 +454,6 @@ public class AccountDataExportController : Controller
|
|||||||
AutoFit(ws, headers.Length);
|
AutoFit(ws, headers.Length);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AddShopWorkersSheet(ExcelPackage pkg, int companyId, Color hdr)
|
|
||||||
{
|
|
||||||
var data = await FetchShopWorkersAsync(companyId);
|
|
||||||
var ws = pkg.Workbook.Worksheets.Add("Shop Workers");
|
|
||||||
var headers = new[] { "ID", "Name", "Role", "Phone", "Email", "Active", "Notes" };
|
|
||||||
WriteHeader(ws, headers, hdr);
|
|
||||||
for (int i = 0; i < data.Count; i++)
|
|
||||||
{
|
|
||||||
var r = i + 2; var w = data[i];
|
|
||||||
ws.Cells[r, 1].Value = w.Id; ws.Cells[r, 2].Value = w.Name;
|
|
||||||
ws.Cells[r, 3].Value = w.Role.ToString(); ws.Cells[r, 4].Value = w.Phone;
|
|
||||||
ws.Cells[r, 5].Value = w.Email; ws.Cells[r, 6].Value = w.IsActive ? "Yes" : "No";
|
|
||||||
ws.Cells[r, 7].Value = w.Notes;
|
|
||||||
}
|
|
||||||
AutoFit(ws, headers.Length);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Adds a "Users" worksheet. All users (active and inactive) are included because Identity
|
/// Adds a "Users" worksheet. All users (active and inactive) are included because Identity
|
||||||
/// uses <c>IsActive = false</c> for soft-deletion; <c>IsDeleted</c> is not applicable here.
|
/// uses <c>IsActive = false</c> for soft-deletion; <c>IsDeleted</c> is not applicable here.
|
||||||
@@ -611,17 +586,6 @@ public class AccountDataExportController : Controller
|
|||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Column names match <c>ShopWorkerImportDto</c> exactly so the file can be re-imported.</summary>
|
|
||||||
private async Task<string> BuildShopWorkersCsv(int companyId)
|
|
||||||
{
|
|
||||||
var data = await FetchShopWorkersAsync(companyId);
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
sb.AppendLine("Name,Role,Phone,Email,IsActive,Notes");
|
|
||||||
foreach (var w in data)
|
|
||||||
sb.AppendLine($"{CsvEscape(w.Name)},{w.Role},{CsvEscape(w.Phone)},{CsvEscape(w.Email)},{w.IsActive.ToString().ToLower()},{CsvEscape(w.Notes)}");
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// All users (active and inactive) are exported for completeness and compliance — mirrors
|
/// All users (active and inactive) are exported for completeness and compliance — mirrors
|
||||||
/// the reasoning in <see cref="AddUsersSheet"/> and <see cref="FetchUsersAsync"/>.
|
/// the reasoning in <see cref="AddUsersSheet"/> and <see cref="FetchUsersAsync"/>.
|
||||||
@@ -675,13 +639,13 @@ public class AccountDataExportController : Controller
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the subset of selected sheet names reordered into the canonical export sequence
|
/// Returns the subset of selected sheet names reordered into the canonical export sequence
|
||||||
/// (Customers → Jobs → Quotes → Invoices → Inventory → Equipment → Vendors → ShopWorkers → Users).
|
/// (Customers → Jobs → Quotes → Invoices → Inventory → Equipment → Vendors → Users).
|
||||||
/// Guarantees consistent file layout regardless of the order check-boxes were ticked on the form.
|
/// Guarantees consistent file layout regardless of the order check-boxes were ticked on the form.
|
||||||
/// Sheet names not in the canonical list are silently dropped.
|
/// Sheet names not in the canonical list are silently dropped.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static string[] OrderSheets(string[] sheets)
|
private static string[] OrderSheets(string[] sheets)
|
||||||
{
|
{
|
||||||
var order = new[] { "Customers", "Jobs", "Quotes", "Invoices", "Inventory", "Equipment", "Vendors", "ShopWorkers", "Users" };
|
var order = new[] { "Customers", "Jobs", "Quotes", "Invoices", "Inventory", "Equipment", "Vendors", "Users" };
|
||||||
return order.Where(sheets.Contains).ToArray();
|
return order.Where(sheets.Contains).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -756,7 +756,6 @@ 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)).OrderBy(o => o.DisplayOrder).ToList();
|
||||||
var workers = (await _unitOfWork.ShopWorkers.FindAsync(w => w.IsActive)).ToList();
|
|
||||||
var coatingCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsCoating)).ToList();
|
var coatingCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsCoating)).ToList();
|
||||||
|
|
||||||
var sb = new System.Text.StringBuilder();
|
var sb = new System.Text.StringBuilder();
|
||||||
@@ -783,8 +782,7 @@ public class CompanySettingsController : Controller
|
|||||||
ShopCapabilityTier.Large => "high-volume",
|
ShopCapabilityTier.Large => "high-volume",
|
||||||
_ => "small"
|
_ => "small"
|
||||||
};
|
};
|
||||||
sb.AppendLine($"We are a {tierLabel} operation" +
|
sb.AppendLine($"We are a {tierLabel} operation.");
|
||||||
(workers.Count > 0 ? $" with {workers.Count} active shop worker{(workers.Count == 1 ? "" : "s")}." : "."));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ovens
|
// Ovens
|
||||||
@@ -827,32 +825,6 @@ public class CompanySettingsController : Controller
|
|||||||
sb.AppendLine($"Powder categories we stock: {string.Join(", ", catNames)}.");
|
sb.AppendLine($"Powder categories we stock: {string.Join(", ", catNames)}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Worker roles
|
|
||||||
if (workers.Any())
|
|
||||||
{
|
|
||||||
var roles = workers
|
|
||||||
.Select(w => w.Role)
|
|
||||||
.Distinct()
|
|
||||||
.Select(r => r switch
|
|
||||||
{
|
|
||||||
ShopWorkerRole.Sandblaster => "sandblasting",
|
|
||||||
ShopWorkerRole.Coater => "powder coating",
|
|
||||||
ShopWorkerRole.Masker => "masking",
|
|
||||||
ShopWorkerRole.QualityControl => "quality control",
|
|
||||||
ShopWorkerRole.OvenOperator => "oven operation",
|
|
||||||
ShopWorkerRole.Supervisor => "supervision",
|
|
||||||
ShopWorkerRole.Maintenance => "equipment maintenance",
|
|
||||||
_ => "general labor"
|
|
||||||
})
|
|
||||||
.Distinct()
|
|
||||||
.ToList();
|
|
||||||
if (roles.Count > 1)
|
|
||||||
{
|
|
||||||
sb.AppendLine();
|
|
||||||
sb.AppendLine($"Staff specialties on hand: {string.Join(", ", roles)}.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rates hint
|
// Rates hint
|
||||||
if (costs != null && costs.StandardLaborRate > 0)
|
if (costs != null && costs.StandardLaborRate > 0)
|
||||||
{
|
{
|
||||||
@@ -2719,79 +2691,6 @@ public class CompanySettingsController : Controller
|
|||||||
|
|
||||||
// ── Role-Based Labor Rates ────────────────────────────────────────────────
|
// ── Role-Based Labor Rates ────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the per-role hourly labor rates configured for the current company, keyed by
|
|
||||||
/// <see cref="PowderCoating.Core.Enums.ShopWorkerRole"/> integer value. An empty list is returned
|
|
||||||
/// (rather than a 404) when no rates have been configured yet, so the UI can render the rate grid
|
|
||||||
/// without special-casing an empty state. The global multi-tenant filter on
|
|
||||||
/// <c>ShopWorkerRoleCosts</c> ensures only this company's rates are returned.
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet]
|
|
||||||
public async Task<IActionResult> GetRoleCosts()
|
|
||||||
{
|
|
||||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
||||||
if (companyId == null) return Json(new List<object>());
|
|
||||||
|
|
||||||
var rates = await _unitOfWork.ShopWorkerRoleCosts.FindAsync(r => r.CompanyId == companyId.Value);
|
|
||||||
var result = rates.Select(r => new { role = (int)r.Role, hourlyRate = r.HourlyRate }).ToList();
|
|
||||||
return Json(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Upserts the per-role hourly labor rates for the current company. The operation handles three cases
|
|
||||||
/// per rate in a single pass: (1) rate cleared (≤ 0) — soft-delete the existing record; (2) rate set
|
|
||||||
/// but no existing record — insert new; (3) rate changed — update existing. This avoids full
|
|
||||||
/// table replace semantics that could cause audit log noise or trigger unintended EF change-tracking.
|
|
||||||
/// These rates are used by the pricing calculator when <c>UseRoleBasedLaborRates</c> is enabled in
|
|
||||||
/// <c>CompanyOperatingCosts</c>.
|
|
||||||
/// </summary>
|
|
||||||
[HttpPost]
|
|
||||||
public async Task<IActionResult> SaveRoleCosts([FromBody] List<SaveRoleCostDto> rates)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
||||||
if (companyId == null) return Json(new { success = false, message = "No company found." });
|
|
||||||
|
|
||||||
var existing = (await _unitOfWork.ShopWorkerRoleCosts.FindAsync(r => r.CompanyId == companyId.Value)).ToList();
|
|
||||||
|
|
||||||
foreach (var dto in rates)
|
|
||||||
{
|
|
||||||
var record = existing.FirstOrDefault(r => (int)r.Role == dto.Role);
|
|
||||||
if (dto.HourlyRate <= 0)
|
|
||||||
{
|
|
||||||
// Remove rate if cleared
|
|
||||||
if (record != null)
|
|
||||||
await _unitOfWork.ShopWorkerRoleCosts.SoftDeleteAsync(record.Id);
|
|
||||||
}
|
|
||||||
else if (record == null)
|
|
||||||
{
|
|
||||||
await _unitOfWork.ShopWorkerRoleCosts.AddAsync(new PowderCoating.Core.Entities.ShopWorkerRoleCost
|
|
||||||
{
|
|
||||||
CompanyId = companyId.Value,
|
|
||||||
Role = (PowderCoating.Core.Enums.ShopWorkerRole)dto.Role,
|
|
||||||
HourlyRate = dto.HourlyRate,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
record.HourlyRate = dto.HourlyRate;
|
|
||||||
record.UpdatedAt = DateTime.UtcNow;
|
|
||||||
await _unitOfWork.ShopWorkerRoleCosts.UpdateAsync(record);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await _unitOfWork.CompleteAsync();
|
|
||||||
return Json(new { success = true });
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error saving role costs");
|
|
||||||
return Json(new { success = false, message = "An error occurred saving role rates." });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Stripe Connect ───────────────────────────────────────────────────────
|
// ─── Stripe Connect ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -3055,7 +2954,6 @@ public class CompanySettingsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
public record SaveTemplateJsonRequest(int Id, string? Subject, string? Body);
|
public record SaveTemplateJsonRequest(int Id, string? Subject, string? Body);
|
||||||
public record SaveRoleCostDto(int Role, decimal HourlyRate);
|
|
||||||
public record SaveOnlinePaymentSettingsDto(
|
public record SaveOnlinePaymentSettingsDto(
|
||||||
OnlinePaymentSurchargeType SurchargeType,
|
OnlinePaymentSurchargeType SurchargeType,
|
||||||
decimal SurchargeValue,
|
decimal SurchargeValue,
|
||||||
|
|||||||
@@ -226,11 +226,9 @@ public class CompanyUsersController : Controller
|
|||||||
/// Creates a new company user, enforcing the subscription user-count limit and a whitelist
|
/// Creates a new company user, enforcing the subscription user-count limit and a whitelist
|
||||||
/// of valid <c>CompanyRole</c> values (preventing callers from submitting a null role to
|
/// of valid <c>CompanyRole</c> values (preventing callers from submitting a null role to
|
||||||
/// create a SuperAdmin-equivalent account). CompanyAdmin users automatically receive all
|
/// create a SuperAdmin-equivalent account). CompanyAdmin users automatically receive all
|
||||||
/// per-feature permissions unless a SuperAdmin is explicitly customising them. Workers
|
/// per-feature permissions unless a SuperAdmin is explicitly customising them. A legacy
|
||||||
/// additionally get an auto-created <see cref="ShopWorker"/> record so they appear in job
|
/// ASP.NET Identity role (Administrator / Manager / Employee / ReadOnly) is also assigned
|
||||||
/// assignment dropdowns without a separate onboarding step. A legacy ASP.NET Identity role
|
/// to satisfy policy checks that still reference the role system.
|
||||||
/// (Administrator / Manager / Employee / ReadOnly) is also assigned to satisfy policy
|
|
||||||
/// checks that still reference the role system.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
// POST: CompanyUsers/Create
|
// POST: CompanyUsers/Create
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
@@ -351,27 +349,7 @@ public class CompanyUsersController : Controller
|
|||||||
|
|
||||||
await _userManager.AddToRoleAsync(user, legacyRole);
|
await _userManager.AddToRoleAsync(user, legacyRole);
|
||||||
|
|
||||||
// If Worker role, automatically create a ShopWorker record
|
_logger.LogInformation("User {Email} created successfully by {Admin}",
|
||||||
if (model.CompanyRole == AppConstants.CompanyRoles.Worker)
|
|
||||||
{
|
|
||||||
var shopWorker = new ShopWorker
|
|
||||||
{
|
|
||||||
Name = user.FullName,
|
|
||||||
Email = user.Email,
|
|
||||||
Phone = user.PhoneNumber,
|
|
||||||
IsActive = true,
|
|
||||||
Notes = $"Auto-created from user account: {user.Email}",
|
|
||||||
Role = Core.Enums.ShopWorkerRole.GeneralLabor, // Default role
|
|
||||||
CompanyId = companyId!.Value
|
|
||||||
};
|
|
||||||
|
|
||||||
await _unitOfWork.ShopWorkers.AddAsync(shopWorker);
|
|
||||||
await _unitOfWork.CompleteAsync();
|
|
||||||
|
|
||||||
_logger.LogInformation("ShopWorker record created for user {Email}", user.Email);
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("User {Email} created successfully by {Admin}",
|
|
||||||
user.Email, User.Identity?.Name);
|
user.Email, User.Identity?.Name);
|
||||||
|
|
||||||
TempData["Success"] = $"User '{user.FullName}' created successfully.";
|
TempData["Success"] = $"User '{user.FullName}' created successfully.";
|
||||||
@@ -441,6 +419,7 @@ public class CompanyUsersController : Controller
|
|||||||
CompanyRole = user.CompanyRole ?? AppConstants.CompanyRoles.Viewer,
|
CompanyRole = user.CompanyRole ?? AppConstants.CompanyRoles.Viewer,
|
||||||
Department = user.Department,
|
Department = user.Department,
|
||||||
Position = user.Position,
|
Position = user.Position,
|
||||||
|
LaborCostPerHour = user.LaborCostPerHour,
|
||||||
Phone = user.PhoneNumber,
|
Phone = user.PhoneNumber,
|
||||||
IsActive = user.IsActive,
|
IsActive = user.IsActive,
|
||||||
HireDate = user.HireDate,
|
HireDate = user.HireDate,
|
||||||
@@ -479,11 +458,9 @@ public class CompanyUsersController : Controller
|
|||||||
/// Saves changes to an existing company user. Validates company isolation and role whitelist
|
/// Saves changes to an existing company user. Validates company isolation and role whitelist
|
||||||
/// (same checks as <see cref="Edit(string, string)"/>). Prevents two dangerous deactivation
|
/// (same checks as <see cref="Edit(string, string)"/>). Prevents two dangerous deactivation
|
||||||
/// scenarios: a user deactivating themselves, and deactivating the last active CompanyAdmin
|
/// scenarios: a user deactivating themselves, and deactivating the last active CompanyAdmin
|
||||||
/// for a company (which would lock out the tenant). When the role changes to Worker and no
|
/// for a company (which would lock out the tenant). Email changes are applied via
|
||||||
/// matching <see cref="ShopWorker"/> record exists, one is created automatically; if one
|
/// <c>SetEmailAsync</c> / <c>SetUserNameAsync</c> after the main update so Identity's own
|
||||||
/// already exists, its name, email, and active status are kept in sync. Email changes are
|
/// normalisation logic runs correctly.
|
||||||
/// applied via <c>SetEmailAsync</c> / <c>SetUserNameAsync</c> after the main update so
|
|
||||||
/// Identity's own normalisation logic runs correctly.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
// POST: CompanyUsers/Edit/id
|
// POST: CompanyUsers/Edit/id
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
@@ -596,6 +573,7 @@ public class CompanyUsersController : Controller
|
|||||||
user.CompanyRole = model.CompanyRole;
|
user.CompanyRole = model.CompanyRole;
|
||||||
user.Department = model.Department;
|
user.Department = model.Department;
|
||||||
user.Position = model.Position;
|
user.Position = model.Position;
|
||||||
|
user.LaborCostPerHour = model.LaborCostPerHour;
|
||||||
user.PhoneNumber = model.Phone;
|
user.PhoneNumber = model.Phone;
|
||||||
user.IsActive = model.IsActive;
|
user.IsActive = model.IsActive;
|
||||||
user.HireDate = model.HireDate;
|
user.HireDate = model.HireDate;
|
||||||
@@ -632,60 +610,7 @@ public class CompanyUsersController : Controller
|
|||||||
user.Id, oldEmail, model.Email, User.Identity?.Name);
|
user.Id, oldEmail, model.Email, User.Identity?.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If role changed to Worker, ensure ShopWorker record exists
|
_logger.LogInformation("User {Email} updated successfully by {Admin}",
|
||||||
if (model.CompanyRole == AppConstants.CompanyRoles.Worker)
|
|
||||||
{
|
|
||||||
// Search by oldEmail so we find the record even when the email just changed
|
|
||||||
var lookupEmail = emailChanged ? oldEmail : user.Email;
|
|
||||||
var existingShopWorker = (await _unitOfWork.ShopWorkers.FindAsync(
|
|
||||||
sw => sw.Email == lookupEmail && sw.CompanyId == user.CompanyId)).ToList();
|
|
||||||
|
|
||||||
if (!existingShopWorker.Any())
|
|
||||||
{
|
|
||||||
var shopWorker = new ShopWorker
|
|
||||||
{
|
|
||||||
Name = user.FullName,
|
|
||||||
Email = user.Email,
|
|
||||||
Phone = user.PhoneNumber,
|
|
||||||
IsActive = user.IsActive,
|
|
||||||
Notes = $"Auto-created from user account: {user.Email}",
|
|
||||||
Role = Core.Enums.ShopWorkerRole.GeneralLabor, // Default role
|
|
||||||
CompanyId = user.CompanyId
|
|
||||||
};
|
|
||||||
|
|
||||||
await _unitOfWork.ShopWorkers.AddAsync(shopWorker);
|
|
||||||
await _unitOfWork.CompleteAsync();
|
|
||||||
|
|
||||||
_logger.LogInformation("ShopWorker record created for user {Email}", user.Email);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Update existing ShopWorker to ensure it's active
|
|
||||||
var shopWorker = existingShopWorker.First();
|
|
||||||
var shopWorkerDirty = false;
|
|
||||||
|
|
||||||
if (!shopWorker.IsActive && user.IsActive)
|
|
||||||
{
|
|
||||||
shopWorker.IsActive = true;
|
|
||||||
shopWorkerDirty = true;
|
|
||||||
_logger.LogInformation("ShopWorker record reactivated for user {Email}", user.Email);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (emailChanged && shopWorker.Email == oldEmail)
|
|
||||||
{
|
|
||||||
shopWorker.Email = user.Email;
|
|
||||||
shopWorkerDirty = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
shopWorker.Name = user.FullName;
|
|
||||||
shopWorker.Phone = user.PhoneNumber;
|
|
||||||
|
|
||||||
if (shopWorkerDirty)
|
|
||||||
await _unitOfWork.CompleteAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("User {Email} updated successfully by {Admin}",
|
|
||||||
user.Email, User.Identity?.Name);
|
user.Email, User.Identity?.Name);
|
||||||
|
|
||||||
TempData["Success"] = "User updated successfully.";
|
TempData["Success"] = "User updated successfully.";
|
||||||
|
|||||||
@@ -122,7 +122,6 @@ public class DataExportController : Controller
|
|||||||
case "Inventory": await AddInventorySheet(package, companyId, headerColor); break;
|
case "Inventory": await AddInventorySheet(package, companyId, headerColor); break;
|
||||||
case "Equipment": await AddEquipmentSheet(package, companyId, headerColor); break;
|
case "Equipment": await AddEquipmentSheet(package, companyId, headerColor); break;
|
||||||
case "Vendors": await AddVendorsSheet(package, companyId, headerColor); break;
|
case "Vendors": await AddVendorsSheet(package, companyId, headerColor); break;
|
||||||
case "ShopWorkers": await AddShopWorkersSheet(package, companyId, headerColor); break;
|
|
||||||
case "Users": await AddUsersSheet(package, companyId, headerColor); break;
|
case "Users": await AddUsersSheet(package, companyId, headerColor); break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,7 +171,6 @@ public class DataExportController : Controller
|
|||||||
case "Inventory": WriteCsvEntry(zip, "Inventory.csv", await BuildInventoryCsv(companyId)); break;
|
case "Inventory": WriteCsvEntry(zip, "Inventory.csv", await BuildInventoryCsv(companyId)); break;
|
||||||
case "Equipment": WriteCsvEntry(zip, "Equipment.csv", await BuildEquipmentCsv(companyId)); break;
|
case "Equipment": WriteCsvEntry(zip, "Equipment.csv", await BuildEquipmentCsv(companyId)); break;
|
||||||
case "Vendors": WriteCsvEntry(zip, "Vendors.csv", await BuildVendorsCsv(companyId)); break;
|
case "Vendors": WriteCsvEntry(zip, "Vendors.csv", await BuildVendorsCsv(companyId)); break;
|
||||||
case "ShopWorkers": WriteCsvEntry(zip, "ShopWorkers.csv", await BuildShopWorkersCsv(companyId)); break;
|
|
||||||
case "Users": WriteCsvEntry(zip, "Users.csv", await BuildUsersCsv(companyId)); break;
|
case "Users": WriteCsvEntry(zip, "Users.csv", await BuildUsersCsv(companyId)); break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -441,38 +439,6 @@ public class DataExportController : Controller
|
|||||||
AutoFit(ws, headers.Length);
|
AutoFit(ws, headers.Length);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Adds a "Shop Workers" worksheet with one row per non-deleted shop worker for the
|
|
||||||
/// specified company. <c>Role.ToString()</c> converts the enum to a string; the view
|
|
||||||
/// typically formats these with spaces (e.g. "QualityControl" → "Quality Control") but the
|
|
||||||
/// raw enum name is used here so the export value is round-trip parseable.
|
|
||||||
/// </summary>
|
|
||||||
private async Task AddShopWorkersSheet(ExcelPackage pkg, int companyId, Color hdr)
|
|
||||||
{
|
|
||||||
var data = await _db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
|
|
||||||
.Where(w => w.CompanyId == companyId && !w.IsDeleted)
|
|
||||||
.OrderBy(w => w.Name)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
var ws = pkg.Workbook.Worksheets.Add("Shop Workers");
|
|
||||||
var headers = new[] { "ID", "Name", "Role", "Phone", "Email", "Active", "Notes" };
|
|
||||||
WriteHeader(ws, headers, hdr);
|
|
||||||
|
|
||||||
for (int i = 0; i < data.Count; i++)
|
|
||||||
{
|
|
||||||
var r = i + 2;
|
|
||||||
var w = data[i];
|
|
||||||
ws.Cells[r, 1].Value = w.Id;
|
|
||||||
ws.Cells[r, 2].Value = w.Name;
|
|
||||||
ws.Cells[r, 3].Value = w.Role.ToString();
|
|
||||||
ws.Cells[r, 4].Value = w.Phone;
|
|
||||||
ws.Cells[r, 5].Value = w.Email;
|
|
||||||
ws.Cells[r, 6].Value = w.IsActive ? "Yes" : "No";
|
|
||||||
ws.Cells[r, 7].Value = w.Notes;
|
|
||||||
}
|
|
||||||
AutoFit(ws, headers.Length);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Adds an "Invoices" worksheet with one row per non-deleted invoice for the specified company.
|
/// Adds an "Invoices" worksheet with one row per non-deleted invoice for the specified company.
|
||||||
/// The customer navigation is eagerly loaded so the customer name can be rendered; when a
|
/// The customer navigation is eagerly loaded so the customer name can be rendered; when a
|
||||||
@@ -687,21 +653,6 @@ public class DataExportController : Controller
|
|||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Builds the shop workers CSV string for the specified company, ordered alphabetically by name.
|
|
||||||
/// Column names match <see cref="ShopWorkerImportDto"/> exactly so the file can be re-imported.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<string> BuildShopWorkersCsv(int companyId)
|
|
||||||
{
|
|
||||||
var data = await _db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
|
|
||||||
.Where(w => w.CompanyId == companyId && !w.IsDeleted).OrderBy(w => w.Name).ToListAsync();
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
sb.AppendLine("Name,Role,Phone,Email,IsActive,Notes");
|
|
||||||
foreach (var w in data)
|
|
||||||
sb.AppendLine($"{CsvEscape(w.Name)},{w.Role},{CsvEscape(w.Phone)},{CsvEscape(w.Email)},{w.IsActive.ToString().ToLower()},{CsvEscape(w.Notes)}");
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds the users CSV string for the specified company, ordered by last name.
|
/// Builds the users CSV string for the specified company, ordered by last name.
|
||||||
/// Like <see cref="AddUsersSheet"/>, the <c>IsDeleted</c> filter is intentionally omitted
|
/// Like <see cref="AddUsersSheet"/>, the <c>IsDeleted</c> filter is intentionally omitted
|
||||||
@@ -769,7 +720,7 @@ public class DataExportController : Controller
|
|||||||
/// <param name="sheets">Raw sheet names from the form POST.</param>
|
/// <param name="sheets">Raw sheet names from the form POST.</param>
|
||||||
private static string[] OrderSheets(string[] sheets)
|
private static string[] OrderSheets(string[] sheets)
|
||||||
{
|
{
|
||||||
var order = new[] { "Customers", "Jobs", "Quotes", "Invoices", "Inventory", "Equipment", "Vendors", "ShopWorkers", "Users" };
|
var order = new[] { "Customers", "Jobs", "Quotes", "Invoices", "Inventory", "Equipment", "Vendors", "Users" };
|
||||||
return order.Where(sheets.Contains).ToArray();
|
return order.Where(sheets.Contains).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -175,7 +175,6 @@ public class DataPurgeController : Controller
|
|||||||
stats.Add(await Stat("Equipment", "Equipment", "bi-tools", "Inventory & Ops", _db.Equipment.Where(e => e.IsDeleted)));
|
stats.Add(await Stat("Equipment", "Equipment", "bi-tools", "Inventory & Ops", _db.Equipment.Where(e => e.IsDeleted)));
|
||||||
stats.Add(await Stat("MaintenanceRecords","Maintenance Records", "bi-wrench", "Inventory & Ops", _db.MaintenanceRecords.Where(e => e.IsDeleted)));
|
stats.Add(await Stat("MaintenanceRecords","Maintenance Records", "bi-wrench", "Inventory & Ops", _db.MaintenanceRecords.Where(e => e.IsDeleted)));
|
||||||
stats.Add(await Stat("Vendors", "Vendors", "bi-truck", "Inventory & Ops", _db.Vendors.Where(e => e.IsDeleted)));
|
stats.Add(await Stat("Vendors", "Vendors", "bi-truck", "Inventory & Ops", _db.Vendors.Where(e => e.IsDeleted)));
|
||||||
stats.Add(await Stat("ShopWorkers", "Shop Workers", "bi-person-badge","Inventory & Ops", _db.ShopWorkers.Where(e => e.IsDeleted)));
|
|
||||||
|
|
||||||
return stats;
|
return stats;
|
||||||
}
|
}
|
||||||
@@ -204,7 +203,6 @@ public class DataPurgeController : Controller
|
|||||||
"Equipment" => await QueryCount(_db.Equipment, cutoff),
|
"Equipment" => await QueryCount(_db.Equipment, cutoff),
|
||||||
"MaintenanceRecords" => await QueryCount(_db.MaintenanceRecords, cutoff),
|
"MaintenanceRecords" => await QueryCount(_db.MaintenanceRecords, cutoff),
|
||||||
"Vendors" => await QueryCount(_db.Vendors, cutoff),
|
"Vendors" => await QueryCount(_db.Vendors, cutoff),
|
||||||
"ShopWorkers" => await QueryCount(_db.ShopWorkers, cutoff),
|
|
||||||
_ => (0, null)
|
_ => (0, null)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -324,11 +322,6 @@ public class DataPurgeController : Controller
|
|||||||
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
|
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "ShopWorkers":
|
|
||||||
count = await _db.ShopWorkers.IgnoreQueryFilters()
|
|
||||||
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,8 @@ public class GiftCertificatesController : Controller
|
|||||||
IssuedReason = gc.IssuedReason,
|
IssuedReason = gc.IssuedReason,
|
||||||
Status = gc.Status,
|
Status = gc.Status,
|
||||||
IssueDate = gc.IssueDate,
|
IssueDate = gc.IssueDate,
|
||||||
ExpiryDate = gc.ExpiryDate
|
ExpiryDate = gc.ExpiryDate,
|
||||||
|
BatchId = gc.BatchId
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
@@ -440,6 +441,183 @@ public class GiftCertificatesController : Controller
|
|||||||
return acct?.Id;
|
return acct?.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shows the bulk certificate creation form. Defaults to Promotional reason and 25 certificates
|
||||||
|
/// since the primary use case is car shows and events where a batch of same-value certificates
|
||||||
|
/// is distributed to attendees.
|
||||||
|
/// </summary>
|
||||||
|
public IActionResult BulkCreate()
|
||||||
|
{
|
||||||
|
return View(new BulkCreateGiftCertificateDto());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates N gift certificates in a single batch, records GL entries for each, then redirects
|
||||||
|
/// to a confirmation page where the user can download the full batch as a single print-ready PDF.
|
||||||
|
/// Certificate codes are generated sequentially so the batch occupies a contiguous range (e.g.
|
||||||
|
/// GC-2506-0012 through GC-2506-0036), making it easy to audit which codes belong to each event.
|
||||||
|
/// GL treatment mirrors single-certificate issuance: Sold certs debit Checking, all others debit
|
||||||
|
/// Sales Discounts (4950) and credit GC Liability (2500).
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> BulkCreate(BulkCreateGiftCertificateDto dto)
|
||||||
|
{
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
return View(dto);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
|
var companyId = currentUser?.CompanyId ?? 0;
|
||||||
|
|
||||||
|
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(companyId);
|
||||||
|
int? checkingAcctId = null;
|
||||||
|
int? discountAcctId = null;
|
||||||
|
|
||||||
|
if (dto.IssuedReason == GiftCertificateIssuedReason.Sold)
|
||||||
|
{
|
||||||
|
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
|
a => a.IsActive && (a.AccountSubType == AccountSubTypeEnum.Checking
|
||||||
|
|| a.AccountSubType == AccountSubTypeEnum.Cash));
|
||||||
|
checkingAcctId = acct?.Id;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
|
a => a.IsActive && a.AccountNumber == "4950");
|
||||||
|
discountAcctId = acct?.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
var batchId = Guid.NewGuid();
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
for (int i = 0; i < dto.Quantity; i++)
|
||||||
|
{
|
||||||
|
var code = await GenerateCertificateCodeAsync(companyId);
|
||||||
|
|
||||||
|
var cert = new GiftCertificate
|
||||||
|
{
|
||||||
|
CertificateCode = code,
|
||||||
|
OriginalAmount = dto.Amount,
|
||||||
|
RedeemedAmount = 0,
|
||||||
|
IssuedReason = dto.IssuedReason,
|
||||||
|
Status = GiftCertificateStatus.Active,
|
||||||
|
IssueDate = now,
|
||||||
|
ExpiryDate = dto.ExpiryDate,
|
||||||
|
Notes = dto.Notes,
|
||||||
|
IssuedById = currentUser?.Id,
|
||||||
|
CompanyId = companyId,
|
||||||
|
CreatedAt = now,
|
||||||
|
CreatedBy = currentUser?.Email,
|
||||||
|
BatchId = batchId
|
||||||
|
};
|
||||||
|
|
||||||
|
await _unitOfWork.GiftCertificates.AddAsync(cert);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
await _accountBalanceService.CreditAsync(gcLiabilityAcctId, cert.OriginalAmount);
|
||||||
|
if (dto.IssuedReason == GiftCertificateIssuedReason.Sold)
|
||||||
|
await _accountBalanceService.DebitAsync(checkingAcctId, cert.OriginalAmount);
|
||||||
|
else
|
||||||
|
await _accountBalanceService.DebitAsync(discountAcctId, cert.OriginalAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RedirectToAction(nameof(BulkResult), new { batchId });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error creating bulk gift certificates");
|
||||||
|
this.ToastError("An error occurred creating the certificates.");
|
||||||
|
return View(dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Displays the batch confirmation page. Driven by BatchId so it is bookmarkable and survives
|
||||||
|
/// browser back/refresh — the user can return here any time to re-download the batch PDF.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IActionResult> BulkResult(Guid batchId)
|
||||||
|
{
|
||||||
|
if (batchId == Guid.Empty)
|
||||||
|
return RedirectToAction(nameof(Index));
|
||||||
|
|
||||||
|
var certs = await _unitOfWork.GiftCertificates.FindAsync(
|
||||||
|
gc => gc.BatchId == batchId, false);
|
||||||
|
|
||||||
|
if (!certs.Any())
|
||||||
|
return RedirectToAction(nameof(Index));
|
||||||
|
|
||||||
|
return View(certs.OrderBy(c => c.CertificateCode).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Streams a multi-page PDF for an entire batch identified by BatchId. GET endpoint so the
|
||||||
|
/// user can bookmark or re-open it at any time after the batch was originally created.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IActionResult> BatchDownloadPdf(Guid batchId)
|
||||||
|
{
|
||||||
|
if (batchId == Guid.Empty)
|
||||||
|
return BadRequest();
|
||||||
|
|
||||||
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
|
var companyId = currentUser?.CompanyId ?? 0;
|
||||||
|
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
||||||
|
|
||||||
|
var companyInfo = new Application.DTOs.Company.CompanyInfoDto
|
||||||
|
{
|
||||||
|
CompanyName = company?.CompanyName ?? string.Empty,
|
||||||
|
Phone = company?.Phone,
|
||||||
|
Address = company?.Address,
|
||||||
|
City = company?.City,
|
||||||
|
State = company?.State,
|
||||||
|
ZipCode = company?.ZipCode,
|
||||||
|
PrimaryContactEmail = company?.PrimaryContactEmail
|
||||||
|
};
|
||||||
|
|
||||||
|
var certs = await _unitOfWork.GiftCertificates.FindAsync(
|
||||||
|
gc => gc.BatchId == batchId, false,
|
||||||
|
gc => gc.RecipientCustomer);
|
||||||
|
|
||||||
|
if (!certs.Any())
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
var dtos = certs.OrderBy(c => c.CertificateCode).Select(cert => new GiftCertificateDto
|
||||||
|
{
|
||||||
|
Id = cert.Id,
|
||||||
|
CertificateCode = cert.CertificateCode,
|
||||||
|
OriginalAmount = cert.OriginalAmount,
|
||||||
|
RedeemedAmount = cert.RedeemedAmount,
|
||||||
|
RemainingBalance = cert.RemainingBalance,
|
||||||
|
RecipientName = cert.RecipientCustomer != null
|
||||||
|
? (cert.RecipientCustomer.CompanyName ?? $"{cert.RecipientCustomer.ContactFirstName} {cert.RecipientCustomer.ContactLastName}".Trim())
|
||||||
|
: cert.RecipientName,
|
||||||
|
RecipientEmail = cert.RecipientEmail,
|
||||||
|
IssuedReason = cert.IssuedReason,
|
||||||
|
Status = cert.Status,
|
||||||
|
IssueDate = cert.IssueDate,
|
||||||
|
ExpiryDate = cert.ExpiryDate,
|
||||||
|
Notes = cert.Notes
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
|
||||||
|
var pdfBytes = await _pdfService.GenerateBulkGiftCertificatePdfAsync(dtos, logoData, logoContentType, companyInfo);
|
||||||
|
var first = dtos.First().CertificateCode;
|
||||||
|
var last = dtos.Last().CertificateCode;
|
||||||
|
var fileName = dtos.Count == 1
|
||||||
|
? $"GiftCertificate-{first}.pdf"
|
||||||
|
: $"GiftCertificates-{first}-to-{last}.pdf";
|
||||||
|
return File(pdfBytes, "application/pdf", fileName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error generating batch gift certificate PDF for batch {BatchId}", batchId);
|
||||||
|
TempData["Error"] = "Could not generate PDF.";
|
||||||
|
return RedirectToAction(nameof(BulkResult), new { batchId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company)
|
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company)
|
||||||
{
|
{
|
||||||
if (company == null) return (null, null);
|
if (company == null) return (null, null);
|
||||||
|
|||||||
@@ -38,14 +38,6 @@ namespace PowderCoating.Web.Controllers
|
|||||||
return View();
|
return View();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Serves the Shop Workers help article describing roles, assignment to jobs, and maintenance tasks.
|
|
||||||
/// </summary>
|
|
||||||
public IActionResult ShopWorkers()
|
|
||||||
{
|
|
||||||
return View();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Serves the Equipment help article explaining the equipment status lifecycle and maintenance scheduling.
|
/// Serves the Equipment help article explaining the equipment status lifecycle and maintenance scheduling.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using PowderCoating.Application.DTOs.Common;
|
using PowderCoating.Application.DTOs.Common;
|
||||||
using PowderCoating.Application.DTOs.Invoice;
|
using PowderCoating.Application.DTOs.Invoice;
|
||||||
|
using PowderCoating.Application.DTOs.Quote;
|
||||||
using PowderCoating.Application.Interfaces;
|
using PowderCoating.Application.Interfaces;
|
||||||
using PowderCoating.Core.Entities;
|
using PowderCoating.Core.Entities;
|
||||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||||
@@ -396,13 +398,15 @@ public class InvoicesController : Controller
|
|||||||
|
|
||||||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||||
{
|
{
|
||||||
SourceJobItemId = item.Id,
|
SourceJobItemId = item.Id,
|
||||||
Description = item.Description ?? "Powder Coating",
|
CatalogItemId = item.CatalogItemId,
|
||||||
Quantity = 1,
|
Description = item.Description ?? "Powder Coating",
|
||||||
UnitPrice = item.TotalPrice,
|
Quantity = item.Quantity > 0 ? item.Quantity : 1,
|
||||||
TotalPrice = item.TotalPrice,
|
UnitPrice = item.UnitPrice,
|
||||||
ColorName = item.ColorName,
|
TotalPrice = item.TotalPrice,
|
||||||
DisplayOrder = order++,
|
ColorName = item.ColorName,
|
||||||
|
Notes = item.Notes,
|
||||||
|
DisplayOrder = order++,
|
||||||
RevenueAccountId = revenueAccountId
|
RevenueAccountId = revenueAccountId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -437,7 +441,10 @@ public class InvoicesController : Controller
|
|||||||
// because FinalPrice is recalculated on every item edit and can drift from the original quote.
|
// because FinalPrice is recalculated on every item edit and can drift from the original quote.
|
||||||
if (sourceQuote != null)
|
if (sourceQuote != null)
|
||||||
{
|
{
|
||||||
|
// Bundle all quote-level charges so the invoice subtotal matches the quote total.
|
||||||
|
// FacilityOverheadCost is included — it is a real cost baked into the quoted price.
|
||||||
var processingFees = sourceQuote.OvenBatchCost
|
var processingFees = sourceQuote.OvenBatchCost
|
||||||
|
+ sourceQuote.FacilityOverheadCost
|
||||||
+ sourceQuote.ShopSuppliesAmount
|
+ sourceQuote.ShopSuppliesAmount
|
||||||
+ sourceQuote.RushFee;
|
+ sourceQuote.RushFee;
|
||||||
|
|
||||||
@@ -460,15 +467,17 @@ public class InvoicesController : Controller
|
|||||||
}
|
}
|
||||||
else if (hadJobItems)
|
else if (hadJobItems)
|
||||||
{
|
{
|
||||||
// Direct job — no source quote. Use the stored job-level fees rather than
|
// Direct job — no source quote. Read all charges from the pricing snapshot so the
|
||||||
// recalculating, so the invoice always matches the total shown on the job page.
|
// invoice always matches the total shown on the job's Pricing Summary card.
|
||||||
// OvenBatchCost and ShopSuppliesAmount are saved by the pricing engine (with
|
QuotePricingBreakdownDto? jobBreakdown = null;
|
||||||
// OvenCostId) when job items are created or updated.
|
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
|
||||||
|
jobBreakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
|
||||||
|
|
||||||
if (job.OvenBatchCost > 0.01m)
|
if (job.OvenBatchCost > 0.01m)
|
||||||
{
|
{
|
||||||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||||
{
|
{
|
||||||
Description = $"Oven Processing Fee",
|
Description = "Oven Processing Fee",
|
||||||
Quantity = 1,
|
Quantity = 1,
|
||||||
UnitPrice = Math.Round(job.OvenBatchCost, 2),
|
UnitPrice = Math.Round(job.OvenBatchCost, 2),
|
||||||
TotalPrice = Math.Round(job.OvenBatchCost, 2),
|
TotalPrice = Math.Round(job.OvenBatchCost, 2),
|
||||||
@@ -477,6 +486,20 @@ public class InvoicesController : Controller
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var facilityOverhead = jobBreakdown?.FacilityOverheadCost ?? 0m;
|
||||||
|
if (facilityOverhead > 0.01m)
|
||||||
|
{
|
||||||
|
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||||
|
{
|
||||||
|
Description = "Facility Overhead",
|
||||||
|
Quantity = 1,
|
||||||
|
UnitPrice = Math.Round(facilityOverhead, 2),
|
||||||
|
TotalPrice = Math.Round(facilityOverhead, 2),
|
||||||
|
DisplayOrder = order++,
|
||||||
|
RevenueAccountId = defaultRevenueAccount?.Id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (job.ShopSuppliesAmount > 0.01m)
|
if (job.ShopSuppliesAmount > 0.01m)
|
||||||
{
|
{
|
||||||
var suppliesDesc = job.ShopSuppliesPercent > 0
|
var suppliesDesc = job.ShopSuppliesPercent > 0
|
||||||
@@ -488,6 +511,20 @@ public class InvoicesController : Controller
|
|||||||
Quantity = 1,
|
Quantity = 1,
|
||||||
UnitPrice = Math.Round(job.ShopSuppliesAmount, 2),
|
UnitPrice = Math.Round(job.ShopSuppliesAmount, 2),
|
||||||
TotalPrice = Math.Round(job.ShopSuppliesAmount, 2),
|
TotalPrice = Math.Round(job.ShopSuppliesAmount, 2),
|
||||||
|
DisplayOrder = order++,
|
||||||
|
RevenueAccountId = defaultRevenueAccount?.Id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var rushFee = jobBreakdown?.RushFee ?? 0m;
|
||||||
|
if (rushFee > 0.01m)
|
||||||
|
{
|
||||||
|
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||||
|
{
|
||||||
|
Description = "Rush Fee",
|
||||||
|
Quantity = 1,
|
||||||
|
UnitPrice = Math.Round(rushFee, 2),
|
||||||
|
TotalPrice = Math.Round(rushFee, 2),
|
||||||
DisplayOrder = order,
|
DisplayOrder = order,
|
||||||
RevenueAccountId = defaultRevenueAccount?.Id
|
RevenueAccountId = defaultRevenueAccount?.Id
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -422,72 +422,24 @@ public class JobsController : Controller
|
|||||||
// Populate Edit Items wizard data (inline modal on Details page)
|
// Populate Edit Items wizard data (inline modal on Details page)
|
||||||
var wizardCosts = await _pricingService.GetOperatingCostsAsync(job.CompanyId);
|
var wizardCosts = await _pricingService.GetOperatingCostsAsync(job.CompanyId);
|
||||||
await PopulateJobItemDropDownsAsync(job.CompanyId, wizardCosts?.OvenOperatingCostPerHour ?? 45m);
|
await PopulateJobItemDropDownsAsync(job.CompanyId, wizardCosts?.OvenOperatingCostPerHour ?? 45m);
|
||||||
ViewBag.WizardTaxPercent = wizardCosts?.TaxPercent ?? 0m;
|
ViewBag.WizardTaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, wizardCosts?.TaxPercent ?? 0m);
|
||||||
|
|
||||||
// Internal pricing breakdown (not printed — mirrors quote details breakdown)
|
// Display the pricing snapshot stored when items were last saved.
|
||||||
var breakdownItems = job.JobItems
|
// Never recalculate on load — operating cost changes must not retroactively alter existing jobs.
|
||||||
.Where(ji => !ji.IsDeleted)
|
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
|
||||||
.Select(ji => new CreateQuoteItemDto
|
|
||||||
{
|
|
||||||
Description = ji.Description,
|
|
||||||
Quantity = ji.Quantity,
|
|
||||||
SurfaceAreaSqFt = ji.SurfaceAreaSqFt,
|
|
||||||
EstimatedMinutes = ji.EstimatedMinutes,
|
|
||||||
CatalogItemId = ji.CatalogItemId,
|
|
||||||
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && !ji.Coats.Any() && !ji.IsSalesItem),
|
|
||||||
IsLaborItem = ji.IsLaborItem,
|
|
||||||
IsSalesItem = ji.IsSalesItem,
|
|
||||||
ManualUnitPrice = ji.ManualUnitPrice ?? ((ji.IsGenericItem || ji.IsSalesItem) ? ji.UnitPrice : (decimal?)null),
|
|
||||||
PowderCostOverride = ji.PowderCostOverride,
|
|
||||||
IncludePrepCost = ji.IncludePrepCost,
|
|
||||||
Complexity = ji.Complexity,
|
|
||||||
Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto
|
|
||||||
{
|
|
||||||
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
|
||||||
TransferEfficiency = c.TransferEfficiency,
|
|
||||||
PowderCostPerLb = c.PowderCostPerLb,
|
|
||||||
PowderToOrder = c.PowderToOrder
|
|
||||||
}).ToList(),
|
|
||||||
PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto
|
|
||||||
{
|
|
||||||
PrepServiceId = ps.PrepServiceId,
|
|
||||||
EstimatedMinutes = ps.EstimatedMinutes
|
|
||||||
}).ToList()
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
if (breakdownItems.Any())
|
|
||||||
{
|
{
|
||||||
var pr = await _pricingService.CalculateQuoteTotalsAsync(
|
ViewBag.JobPricingBreakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
|
||||||
breakdownItems, job.CompanyId, job.CustomerId,
|
}
|
||||||
wizardCosts?.TaxPercent ?? 0m,
|
else if (job.FinalPrice > 0)
|
||||||
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
|
{
|
||||||
job.OvenCostId, 1, null);
|
// Legacy job created before snapshot was introduced — show what we have stored
|
||||||
|
|
||||||
ViewBag.JobPricingBreakdown = new QuotePricingBreakdownDto
|
ViewBag.JobPricingBreakdown = new QuotePricingBreakdownDto
|
||||||
{
|
{
|
||||||
MaterialCosts = pr.MaterialCosts,
|
OvenBatchCost = job.OvenBatchCost,
|
||||||
LaborCosts = pr.LaborCosts,
|
OvenBatches = job.OvenBatches,
|
||||||
EquipmentCosts = pr.EquipmentCosts,
|
ShopSuppliesAmount = job.ShopSuppliesAmount,
|
||||||
ItemsSubtotal = pr.ItemsSubtotal,
|
ShopSuppliesPercent = job.ShopSuppliesPercent,
|
||||||
OvenBatchCost = pr.OvenBatchCost,
|
Total = job.FinalPrice
|
||||||
OvenBatches = pr.OvenBatches,
|
|
||||||
OvenCycleMinutes = pr.OvenCycleMinutes > 0 ? pr.OvenCycleMinutes : (wizardCosts?.DefaultOvenCycleMinutes ?? 0),
|
|
||||||
FacilityOverheadCost = pr.FacilityOverheadCost,
|
|
||||||
FacilityOverheadRatePerHour = pr.FacilityOverheadRatePerHour,
|
|
||||||
ShopSuppliesAmount = pr.ShopSuppliesAmount,
|
|
||||||
ShopSuppliesPercent = pr.ShopSuppliesPercent,
|
|
||||||
OverheadCosts = pr.OverheadCosts,
|
|
||||||
OverheadPercent = pr.OverheadPercent,
|
|
||||||
ProfitMargin = pr.ProfitMargin,
|
|
||||||
ProfitPercent = pr.ProfitPercent,
|
|
||||||
SubtotalBeforeDiscount = pr.SubtotalBeforeDiscount,
|
|
||||||
DiscountAmount = pr.DiscountAmount,
|
|
||||||
DiscountPercent = pr.DiscountPercent,
|
|
||||||
SubtotalAfterDiscount = pr.SubtotalAfterDiscount,
|
|
||||||
RushFee = pr.RushFee,
|
|
||||||
TaxAmount = pr.TaxAmount,
|
|
||||||
TaxPercent = pr.TaxPercent,
|
|
||||||
Total = pr.Total
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
ViewBag.ComplexitySimplePercent = wizardCosts?.ComplexitySimplePercent ?? 0m;
|
ViewBag.ComplexitySimplePercent = wizardCosts?.ComplexitySimplePercent ?? 0m;
|
||||||
@@ -506,6 +458,7 @@ public class JobsController : Controller
|
|||||||
isGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && !ji.Coats.Any() && !ji.IsSalesItem),
|
isGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && !ji.Coats.Any() && !ji.IsSalesItem),
|
||||||
isLaborItem = ji.IsLaborItem,
|
isLaborItem = ji.IsLaborItem,
|
||||||
isSalesItem = ji.IsSalesItem,
|
isSalesItem = ji.IsSalesItem,
|
||||||
|
isAiItem = ji.IsAiItem,
|
||||||
sku = ji.Sku,
|
sku = ji.Sku,
|
||||||
requiresSandblasting = ji.RequiresSandblasting,
|
requiresSandblasting = ji.RequiresSandblasting,
|
||||||
requiresMasking = ji.RequiresMasking,
|
requiresMasking = ji.RequiresMasking,
|
||||||
@@ -1106,6 +1059,7 @@ public class JobsController : Controller
|
|||||||
CustomerId = dto.CustomerId,
|
CustomerId = dto.CustomerId,
|
||||||
QuoteId = dto.QuoteId,
|
QuoteId = dto.QuoteId,
|
||||||
AssignedUserId = dto.AssignedUserId,
|
AssignedUserId = dto.AssignedUserId,
|
||||||
|
OvenCostId = dto.OvenCostId,
|
||||||
Description = dto.Description,
|
Description = dto.Description,
|
||||||
JobPriorityId = dto.JobPriorityId,
|
JobPriorityId = dto.JobPriorityId,
|
||||||
JobStatusId = pendingStatus?.Id ?? 1,
|
JobStatusId = pendingStatus?.Id ?? 1,
|
||||||
@@ -1167,15 +1121,23 @@ public class JobsController : Controller
|
|||||||
|
|
||||||
// Recalculate total from wizard items
|
// Recalculate total from wizard items
|
||||||
var createCosts = await _pricingService.GetOperatingCostsAsync(companyId);
|
var createCosts = await _pricingService.GetOperatingCostsAsync(companyId);
|
||||||
|
decimal? createOvenRate = null;
|
||||||
|
if (dto.OvenCostId.HasValue)
|
||||||
|
{
|
||||||
|
var createOven = await _unitOfWork.OvenCosts.GetByIdAsync(dto.OvenCostId.Value);
|
||||||
|
if (createOven != null && createOven.CompanyId == companyId)
|
||||||
|
createOvenRate = createOven.CostPerHour;
|
||||||
|
}
|
||||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||||
dto.JobItems, companyId, dto.CustomerId,
|
dto.JobItems, companyId, dto.CustomerId,
|
||||||
createCosts?.TaxPercent ?? 0m,
|
await GetEffectiveTaxPercentAsync(dto.CustomerId, createCosts?.TaxPercent ?? 0m),
|
||||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, job.OvenCostId, 1, null);
|
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, createOvenRate, job.OvenBatches, job.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;
|
||||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||||
|
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
|
||||||
job.UpdatedAt = DateTime.UtcNow;
|
job.UpdatedAt = DateTime.UtcNow;
|
||||||
await _unitOfWork.Jobs.UpdateAsync(job);
|
await _unitOfWork.Jobs.UpdateAsync(job);
|
||||||
await _unitOfWork.SaveChangesAsync();
|
await _unitOfWork.SaveChangesAsync();
|
||||||
@@ -1262,6 +1224,7 @@ public class JobsController : Controller
|
|||||||
PowderCostOverride = ji.PowderCostOverride,
|
PowderCostOverride = ji.PowderCostOverride,
|
||||||
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0),
|
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0),
|
||||||
IsLaborItem = ji.IsLaborItem,
|
IsLaborItem = ji.IsLaborItem,
|
||||||
|
IsAiItem = ji.IsAiItem,
|
||||||
RequiresSandblasting = ji.RequiresSandblasting,
|
RequiresSandblasting = ji.RequiresSandblasting,
|
||||||
RequiresMasking = ji.RequiresMasking,
|
RequiresMasking = ji.RequiresMasking,
|
||||||
Notes = ji.Notes,
|
Notes = ji.Notes,
|
||||||
@@ -1626,14 +1589,22 @@ public class JobsController : Controller
|
|||||||
if (dto.JobItems.Any())
|
if (dto.JobItems.Any())
|
||||||
{
|
{
|
||||||
var editCosts = await _pricingService.GetOperatingCostsAsync(companyId);
|
var editCosts = await _pricingService.GetOperatingCostsAsync(companyId);
|
||||||
|
decimal? editOvenRate = null;
|
||||||
|
if (job.OvenCostId.HasValue)
|
||||||
|
{
|
||||||
|
var editOven = await _unitOfWork.OvenCosts.GetByIdAsync(job.OvenCostId.Value);
|
||||||
|
if (editOven != null && editOven.CompanyId == companyId)
|
||||||
|
editOvenRate = editOven.CostPerHour;
|
||||||
|
}
|
||||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||||
dto.JobItems, companyId, dto.CustomerId,
|
dto.JobItems, companyId, dto.CustomerId,
|
||||||
editCosts?.TaxPercent ?? 0m,
|
await GetEffectiveTaxPercentAsync(dto.CustomerId, editCosts?.TaxPercent ?? 0m),
|
||||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, job.OvenCostId, 1, null);
|
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, editOvenRate, job.OvenBatches, job.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;
|
||||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||||
|
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save change history records
|
// Save change history records
|
||||||
@@ -2926,6 +2897,7 @@ public class JobsController : Controller
|
|||||||
PowderCostOverride = ji.PowderCostOverride,
|
PowderCostOverride = ji.PowderCostOverride,
|
||||||
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0),
|
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0),
|
||||||
IsLaborItem = ji.IsLaborItem,
|
IsLaborItem = ji.IsLaborItem,
|
||||||
|
IsAiItem = ji.IsAiItem,
|
||||||
RequiresSandblasting = ji.RequiresSandblasting,
|
RequiresSandblasting = ji.RequiresSandblasting,
|
||||||
RequiresMasking = ji.RequiresMasking,
|
RequiresMasking = ji.RequiresMasking,
|
||||||
Notes = ji.Notes,
|
Notes = ji.Notes,
|
||||||
@@ -2955,11 +2927,14 @@ public class JobsController : Controller
|
|||||||
|
|
||||||
var viewModel = new JobEditItemsViewModel
|
var viewModel = new JobEditItemsViewModel
|
||||||
{
|
{
|
||||||
JobId = job.Id,
|
JobId = job.Id,
|
||||||
JobNumber = job.JobNumber,
|
JobNumber = job.JobNumber,
|
||||||
CustomerId = job.CustomerId,
|
CustomerId = job.CustomerId,
|
||||||
TaxPercent = costs?.TaxPercent ?? 0m,
|
TaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m),
|
||||||
JobItems = existingItems
|
OvenCostId = job.OvenCostId,
|
||||||
|
OvenBatches = job.OvenBatches > 0 ? job.OvenBatches : 1,
|
||||||
|
OvenCycleMinutes = job.OvenCycleMinutes,
|
||||||
|
JobItems = existingItems
|
||||||
};
|
};
|
||||||
|
|
||||||
await PopulateJobItemDropDownsAsync(currentUser.CompanyId, costs?.OvenOperatingCostPerHour ?? 45m);
|
await PopulateJobItemDropDownsAsync(currentUser.CompanyId, costs?.OvenOperatingCostPerHour ?? 45m);
|
||||||
@@ -2992,7 +2967,7 @@ public class JobsController : Controller
|
|||||||
{
|
{
|
||||||
ModelState.AddModelError("", "Please add at least one job item.");
|
ModelState.AddModelError("", "Please add at least one job item.");
|
||||||
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
||||||
model.TaxPercent = costs?.TaxPercent ?? 0m;
|
model.TaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m);
|
||||||
await PopulateJobItemDropDownsAsync(currentUser.CompanyId, costs?.OvenOperatingCostPerHour ?? 45m);
|
await PopulateJobItemDropDownsAsync(currentUser.CompanyId, costs?.OvenOperatingCostPerHour ?? 45m);
|
||||||
ViewBag.ComplexitySimplePercent = costs?.ComplexitySimplePercent ?? 0m;
|
ViewBag.ComplexitySimplePercent = costs?.ComplexitySimplePercent ?? 0m;
|
||||||
ViewBag.ComplexityModeratePercent = costs?.ComplexityModeratePercent ?? 5m;
|
ViewBag.ComplexityModeratePercent = costs?.ComplexityModeratePercent ?? 5m;
|
||||||
@@ -3037,15 +3012,26 @@ public class JobsController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate full total (overhead, margins, tax) to match what the wizard displays
|
// Calculate full total (overhead, margins, tax) matching what Details shows
|
||||||
|
decimal? ovenRateOverride = null;
|
||||||
|
if (job.OvenCostId.HasValue)
|
||||||
|
{
|
||||||
|
var oven = await _unitOfWork.OvenCosts.GetByIdAsync(job.OvenCostId.Value);
|
||||||
|
if (oven != null && oven.CompanyId == currentUser.CompanyId)
|
||||||
|
ovenRateOverride = oven.CostPerHour;
|
||||||
|
}
|
||||||
|
var updateCosts = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
||||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||||
model.JobItems, currentUser.CompanyId, job.CustomerId,
|
model.JobItems, currentUser.CompanyId, job.CustomerId,
|
||||||
model.TaxPercent, "None", 0, false, job.OvenCostId, 1, null);
|
await GetEffectiveTaxPercentAsync(job.CustomerId, updateCosts?.TaxPercent ?? 0m),
|
||||||
|
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
|
||||||
|
ovenRateOverride, job.OvenBatches, job.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;
|
||||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||||
|
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
|
||||||
job.UpdatedAt = DateTime.UtcNow;
|
job.UpdatedAt = DateTime.UtcNow;
|
||||||
job.UpdatedBy = currentUser.UserName;
|
job.UpdatedBy = currentUser.UserName;
|
||||||
await _unitOfWork.Jobs.UpdateAsync(job);
|
await _unitOfWork.Jobs.UpdateAsync(job);
|
||||||
@@ -3059,7 +3045,7 @@ public class JobsController : Controller
|
|||||||
_logger.LogError(ex, "Error updating items for job {JobId}", job.Id);
|
_logger.LogError(ex, "Error updating items for job {JobId}", job.Id);
|
||||||
TempData["Error"] = "An error occurred while saving job items.";
|
TempData["Error"] = "An error occurred while saving job items.";
|
||||||
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
||||||
model.TaxPercent = costs?.TaxPercent ?? 0m;
|
model.TaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m);
|
||||||
await PopulateJobItemDropDownsAsync(currentUser.CompanyId, costs?.OvenOperatingCostPerHour ?? 45m);
|
await PopulateJobItemDropDownsAsync(currentUser.CompanyId, costs?.OvenOperatingCostPerHour ?? 45m);
|
||||||
return View("EditItems", model);
|
return View("EditItems", model);
|
||||||
}
|
}
|
||||||
@@ -3101,30 +3087,47 @@ public class JobsController : Controller
|
|||||||
CatalogItemId = ji.CatalogItemId,
|
CatalogItemId = ji.CatalogItemId,
|
||||||
IsGenericItem = ji.IsGenericItem,
|
IsGenericItem = ji.IsGenericItem,
|
||||||
IsLaborItem = ji.IsLaborItem,
|
IsLaborItem = ji.IsLaborItem,
|
||||||
ManualUnitPrice = ji.ManualUnitPrice,
|
IsSalesItem = ji.IsSalesItem,
|
||||||
Coats = ji.Coats.Select(c => new CreateQuoteItemCoatDto
|
IsAiItem = ji.IsAiItem,
|
||||||
|
ManualUnitPrice = ji.ManualUnitPrice ?? ((ji.IsGenericItem || ji.IsSalesItem) ? ji.UnitPrice : (decimal?)null),
|
||||||
|
IncludePrepCost = ji.IncludePrepCost,
|
||||||
|
Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto
|
||||||
{
|
{
|
||||||
|
InventoryItemId = c.InventoryItemId,
|
||||||
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
||||||
TransferEfficiency = c.TransferEfficiency,
|
TransferEfficiency = c.TransferEfficiency,
|
||||||
PowderCostPerLb = c.PowderCostPerLb
|
PowderCostPerLb = c.PowderCostPerLb
|
||||||
}).ToList()
|
}).ToList()
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
|
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
||||||
if (remainingDtos.Any())
|
if (remainingDtos.Any())
|
||||||
{
|
{
|
||||||
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
decimal? deleteOvenRate = null;
|
||||||
|
if (job.OvenCostId.HasValue)
|
||||||
|
{
|
||||||
|
var deleteOven = await _unitOfWork.OvenCosts.GetByIdAsync(job.OvenCostId.Value);
|
||||||
|
if (deleteOven != null && deleteOven.CompanyId == currentUser.CompanyId)
|
||||||
|
deleteOvenRate = deleteOven.CostPerHour;
|
||||||
|
}
|
||||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||||
remainingDtos, currentUser.CompanyId, job.CustomerId,
|
remainingDtos, currentUser.CompanyId, job.CustomerId,
|
||||||
costs?.TaxPercent ?? 0m, "None", 0, false, null, 1, null);
|
await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m),
|
||||||
job.FinalPrice = totals.Total;
|
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
|
||||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
deleteOvenRate, job.OvenBatches, job.OvenCycleMinutes);
|
||||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
job.FinalPrice = totals.Total;
|
||||||
|
job.OvenBatchCost = totals.OvenBatchCost;
|
||||||
|
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||||
|
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||||
|
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
job.FinalPrice = 0;
|
job.FinalPrice = 0;
|
||||||
job.ShopSuppliesAmount = 0;
|
job.OvenBatchCost = 0;
|
||||||
job.ShopSuppliesPercent = 0;
|
job.ShopSuppliesAmount = 0;
|
||||||
|
job.ShopSuppliesPercent = 0;
|
||||||
|
job.PricingBreakdownJson = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
job.UpdatedAt = DateTime.UtcNow;
|
job.UpdatedAt = DateTime.UtcNow;
|
||||||
@@ -3234,6 +3237,57 @@ public class JobsController : Controller
|
|||||||
return $"{string.Join(" > ", path)} > {item.Name}{sku} - {item.DefaultPrice:C}";
|
return $"{string.Join(" > ", path)} > {item.Name}{sku} - {item.DefaultPrice:C}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a <see cref="QuotePricingResult"/> into the DTO used for both display and JSON snapshot storage.
|
||||||
|
/// All save paths (Create, Edit, UpdateItems, DeleteJobItem) call this so the snapshot is always consistent.
|
||||||
|
/// </summary>
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the effective tax rate for a job, respecting customer tax-exempt status.
|
||||||
|
/// Always call this instead of using costs.TaxPercent directly so tax-exempt customers
|
||||||
|
/// are never charged tax when a job is saved or recalculated.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<decimal> GetEffectiveTaxPercentAsync(int? customerId, decimal companyDefaultRate)
|
||||||
|
{
|
||||||
|
if (customerId is > 0)
|
||||||
|
{
|
||||||
|
var customer = await _unitOfWork.Customers.GetByIdAsync(customerId.Value);
|
||||||
|
if (customer?.IsTaxExempt == true) return 0m;
|
||||||
|
}
|
||||||
|
return companyDefaultRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static QuotePricingBreakdownDto BuildPricingSnapshotDto(QuotePricingResult pr) =>
|
||||||
|
new QuotePricingBreakdownDto
|
||||||
|
{
|
||||||
|
MaterialCosts = pr.MaterialCosts,
|
||||||
|
LaborCosts = pr.LaborCosts,
|
||||||
|
EquipmentCosts = pr.EquipmentCosts,
|
||||||
|
ItemsSubtotal = pr.ItemsSubtotal,
|
||||||
|
OvenBatchCost = pr.OvenBatchCost,
|
||||||
|
OvenBatches = pr.OvenBatches,
|
||||||
|
OvenCycleMinutes = pr.OvenCycleMinutes,
|
||||||
|
FacilityOverheadCost = pr.FacilityOverheadCost,
|
||||||
|
FacilityOverheadRatePerHour = pr.FacilityOverheadRatePerHour,
|
||||||
|
ShopSuppliesAmount = pr.ShopSuppliesAmount,
|
||||||
|
ShopSuppliesPercent = pr.ShopSuppliesPercent,
|
||||||
|
OverheadCosts = pr.OverheadCosts,
|
||||||
|
OverheadPercent = pr.OverheadPercent,
|
||||||
|
ProfitMargin = pr.ProfitMargin,
|
||||||
|
ProfitPercent = pr.ProfitPercent,
|
||||||
|
SubtotalBeforeDiscount = pr.SubtotalBeforeDiscount,
|
||||||
|
PricingTierDiscountAmount = pr.PricingTierDiscountAmount,
|
||||||
|
PricingTierDiscountPercent = pr.PricingTierDiscountPercent,
|
||||||
|
QuoteDiscountAmount = pr.QuoteDiscountAmount,
|
||||||
|
QuoteDiscountPercent = pr.QuoteDiscountPercent,
|
||||||
|
DiscountAmount = pr.DiscountAmount,
|
||||||
|
DiscountPercent = pr.DiscountPercent,
|
||||||
|
SubtotalAfterDiscount = pr.SubtotalAfterDiscount,
|
||||||
|
RushFee = pr.RushFee,
|
||||||
|
TaxAmount = pr.TaxAmount,
|
||||||
|
TaxPercent = pr.TaxPercent,
|
||||||
|
Total = pr.Total
|
||||||
|
};
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Item Pricing (AJAX)
|
#region Item Pricing (AJAX)
|
||||||
@@ -3314,8 +3368,7 @@ public class JobsController : Controller
|
|||||||
public async Task<IActionResult> GetTimeEntries(int jobId)
|
public async Task<IActionResult> GetTimeEntries(int jobId)
|
||||||
{
|
{
|
||||||
var entries = await _unitOfWork.JobTimeEntries.FindAsync(
|
var entries = await _unitOfWork.JobTimeEntries.FindAsync(
|
||||||
e => e.JobId == jobId, false,
|
e => e.JobId == jobId, false);
|
||||||
e => e.Worker); // Worker nav loaded for display of legacy entries that pre-date user migration
|
|
||||||
var dtos = _mapper.Map<List<JobTimeEntryDto>>(entries.OrderByDescending(e => e.WorkDate).ToList());
|
var dtos = _mapper.Map<List<JobTimeEntryDto>>(entries.OrderByDescending(e => e.WorkDate).ToList());
|
||||||
return Json(dtos);
|
return Json(dtos);
|
||||||
}
|
}
|
||||||
@@ -3769,15 +3822,24 @@ public class JobsController : Controller
|
|||||||
|
|
||||||
// Operating costs for fallback labor rate and oven rate
|
// Operating costs for fallback labor rate and oven rate
|
||||||
var opCosts = (await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == companyId)).FirstOrDefault();
|
var opCosts = (await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == companyId)).FirstOrDefault();
|
||||||
var fallbackLaborRate = opCosts?.StandardLaborRate ?? 0m;
|
|
||||||
var effectiveOvenMinutes = (opCosts?.DefaultOvenCycleMinutes > 0 ? (int?)opCosts!.DefaultOvenCycleMinutes : null) ?? 45;
|
var effectiveOvenMinutes = (opCosts?.DefaultOvenCycleMinutes > 0 ? (int?)opCosts!.DefaultOvenCycleMinutes : null) ?? 45;
|
||||||
var defaultOvenCycleHours = effectiveOvenMinutes / 60.0m;
|
var defaultOvenCycleHours = effectiveOvenMinutes / 60.0m;
|
||||||
|
|
||||||
// Role cost rates map: role → hourly rate
|
// Labor cost rate priority: per-user LaborCostPerHour → company LaborCostPerHour → 20% of StandardLaborRate
|
||||||
var roleCosts = await _unitOfWork.ShopWorkerRoleCosts.FindAsync(r => r.CompanyId == companyId);
|
var companyLaborCostRate = opCosts?.LaborCostPerHour ?? ((opCosts?.StandardLaborRate ?? 0m) * 0.20m);
|
||||||
var roleCostMap = roleCosts.ToDictionary(r => r.Role, r => r.HourlyRate);
|
var companyUsers = await _userManager.Users
|
||||||
|
.Where(u => u.CompanyId == companyId && u.LaborCostPerHour != null)
|
||||||
|
.Select(u => new { u.Id, u.LaborCostPerHour })
|
||||||
|
.ToListAsync();
|
||||||
|
var userLaborCostMap = companyUsers.ToDictionary(u => u.Id, u => u.LaborCostPerHour!.Value);
|
||||||
|
|
||||||
// 1. Powder / Material cost
|
// 1. Powder / Material cost
|
||||||
|
// Priority: PowderUsageLog actuals (sum per coat) > coat.ActualPowderUsedLbs > coat.PowderToOrder (estimated)
|
||||||
|
var usageLogs = await _unitOfWork.PowderUsageLogs.FindAsync(u => u.JobId == jobId);
|
||||||
|
var actualByCoat = usageLogs
|
||||||
|
.GroupBy(u => u.JobItemCoatId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Sum(u => u.ActualLbsUsed));
|
||||||
|
|
||||||
decimal powderCost = 0m;
|
decimal powderCost = 0m;
|
||||||
var powderLines = new List<object>();
|
var powderLines = new List<object>();
|
||||||
bool hasCoatsWithRateButNoQty = false;
|
bool hasCoatsWithRateButNoQty = false;
|
||||||
@@ -3785,7 +3847,19 @@ public class JobsController : Controller
|
|||||||
{
|
{
|
||||||
foreach (var coat in item.Coats)
|
foreach (var coat in item.Coats)
|
||||||
{
|
{
|
||||||
var lbs = coat.ActualPowderUsedLbs ?? coat.PowderToOrder ?? 0m;
|
bool isActual;
|
||||||
|
decimal lbs;
|
||||||
|
if (actualByCoat.TryGetValue(coat.Id, out var loggedLbs) && loggedLbs > 0)
|
||||||
|
{
|
||||||
|
lbs = loggedLbs;
|
||||||
|
isActual = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
lbs = coat.ActualPowderUsedLbs ?? coat.PowderToOrder ?? 0m;
|
||||||
|
isActual = coat.ActualPowderUsedLbs.HasValue;
|
||||||
|
}
|
||||||
|
|
||||||
var costPerLb = coat.PowderCostPerLb ?? 0m;
|
var costPerLb = coat.PowderCostPerLb ?? 0m;
|
||||||
var lineCost = lbs * costPerLb;
|
var lineCost = lbs * costPerLb;
|
||||||
powderCost += lineCost;
|
powderCost += lineCost;
|
||||||
@@ -3796,7 +3870,7 @@ public class JobsController : Controller
|
|||||||
lbs = Math.Round(lbs, 3),
|
lbs = Math.Round(lbs, 3),
|
||||||
costPerLb = Math.Round(costPerLb, 4),
|
costPerLb = Math.Round(costPerLb, 4),
|
||||||
total = Math.Round(lineCost, 2),
|
total = Math.Round(lineCost, 2),
|
||||||
isActual = coat.ActualPowderUsedLbs.HasValue
|
isActual
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
else if (costPerLb > 0 && lbs == 0)
|
else if (costPerLb > 0 && lbs == 0)
|
||||||
@@ -3808,20 +3882,23 @@ public class JobsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Labor cost
|
// 2. Labor cost
|
||||||
|
// Priority: per-user LaborCostPerHour → company LaborCostPerHour → 20% of StandardLaborRate
|
||||||
decimal laborCost = 0m;
|
decimal laborCost = 0m;
|
||||||
var laborLines = new List<object>();
|
var laborLines = new List<object>();
|
||||||
foreach (var entry in job.TimeEntries)
|
foreach (var entry in job.TimeEntries)
|
||||||
{
|
{
|
||||||
var rate = entry.Worker != null && roleCostMap.TryGetValue(entry.Worker.Role, out var r) ? r : fallbackLaborRate;
|
bool usingPerUser = entry.UserId != null && userLaborCostMap.TryGetValue(entry.UserId, out _);
|
||||||
|
var rate = usingPerUser
|
||||||
|
? userLaborCostMap[entry.UserId!]
|
||||||
|
: companyLaborCostRate;
|
||||||
var lineCost = entry.HoursWorked * rate;
|
var lineCost = entry.HoursWorked * rate;
|
||||||
laborCost += lineCost;
|
laborCost += lineCost;
|
||||||
laborLines.Add(new {
|
laborLines.Add(new {
|
||||||
worker = entry.Worker?.Name ?? "Unknown",
|
worker = entry.UserDisplayName ?? "Unknown",
|
||||||
role = entry.Worker != null ? System.Text.RegularExpressions.Regex.Replace(entry.Worker.Role.ToString(), "([a-z])([A-Z])", "$1 $2") : "",
|
|
||||||
hours = entry.HoursWorked,
|
hours = entry.HoursWorked,
|
||||||
rate = Math.Round(rate, 2),
|
rate = Math.Round(rate, 2),
|
||||||
total = Math.Round(lineCost, 2),
|
total = Math.Round(lineCost, 2),
|
||||||
usingFallback = entry.Worker == null || !roleCostMap.ContainsKey(entry.Worker.Role),
|
usingFallback = !usingPerUser,
|
||||||
stage = entry.Stage,
|
stage = entry.Stage,
|
||||||
workDate = entry.WorkDate.ToString("MM/dd/yyyy")
|
workDate = entry.WorkDate.ToString("MM/dd/yyyy")
|
||||||
});
|
});
|
||||||
@@ -3895,7 +3972,7 @@ public class JobsController : Controller
|
|||||||
grossMargin,
|
grossMargin,
|
||||||
quotedMargin,
|
quotedMargin,
|
||||||
quotedPrice = Math.Round(job.QuotedPrice, 2),
|
quotedPrice = Math.Round(job.QuotedPrice, 2),
|
||||||
fallbackLaborRate,
|
companyLaborCostRate,
|
||||||
powderLines,
|
powderLines,
|
||||||
laborLines,
|
laborLines,
|
||||||
hasPowderData = powderLines.Count > 0,
|
hasPowderData = powderLines.Count > 0,
|
||||||
|
|||||||
@@ -916,8 +916,13 @@ public class KioskController : Controller
|
|||||||
ViewBag.SessionToken = session.SessionToken;
|
ViewBag.SessionToken = session.SessionToken;
|
||||||
ViewBag.SessionType = session.SessionType;
|
ViewBag.SessionType = session.SessionType;
|
||||||
|
|
||||||
// Reset to Welcome screen after 45 s of inactivity on any intake step.
|
// In-person kiosk: reset to Welcome screen after 45 s of inactivity so an
|
||||||
// The Welcome screen itself stays on indefinitely (no timeout override there).
|
// abandoned tablet doesn't stay on a customer's half-filled form indefinitely.
|
||||||
ViewBag.InactivityTimeoutMs = 45_000;
|
// Remote sessions: customer is on their own phone — never redirect; they may
|
||||||
|
// take several minutes between steps and have no KioskDevice cookie anyway.
|
||||||
|
if (session.SessionType == KioskSessionType.InPerson)
|
||||||
|
ViewBag.InactivityTimeoutMs = 45_000;
|
||||||
|
else
|
||||||
|
ViewBag.ShowInactivityTimer = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using AutoMapper;
|
using System.Text.Json;
|
||||||
|
using AutoMapper;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using PowderCoating.Shared.Constants;
|
using PowderCoating.Shared.Constants;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
@@ -2839,14 +2840,47 @@ public class QuotesController : Controller
|
|||||||
JobNumber = await GenerateJobNumberAsync(),
|
JobNumber = await GenerateJobNumberAsync(),
|
||||||
CustomerId = quote.CustomerId ?? 0, // Should always have a customer by approval time
|
CustomerId = quote.CustomerId ?? 0, // Should always have a customer by approval time
|
||||||
QuoteId = quote.Id,
|
QuoteId = quote.Id,
|
||||||
OvenCostId = quote.OvenCostId, // Carry oven selection from quote
|
OvenCostId = quote.OvenCostId, // Carry oven selection from quote
|
||||||
|
OvenBatches = quote.OvenBatches > 0 ? quote.OvenBatches : 1,
|
||||||
|
OvenCycleMinutes = quote.OvenCycleMinutes,
|
||||||
Description = quote.Description ?? $"Job from Quote {quote.QuoteNumber}",
|
Description = quote.Description ?? $"Job from Quote {quote.QuoteNumber}",
|
||||||
JobStatusId = approvedStatus?.Id ?? 1,
|
JobStatusId = approvedStatus?.Id ?? 1,
|
||||||
JobPriorityId = selectedPriority?.Id ?? 1,
|
JobPriorityId = selectedPriority?.Id ?? 1,
|
||||||
QuotedPrice = quote.Total,
|
QuotedPrice = quote.Total,
|
||||||
FinalPrice = quote.Total,
|
FinalPrice = quote.Total,
|
||||||
|
OvenBatchCost = quote.OvenBatchCost,
|
||||||
ShopSuppliesAmount = quote.ShopSuppliesAmount,
|
ShopSuppliesAmount = quote.ShopSuppliesAmount,
|
||||||
ShopSuppliesPercent = quote.ShopSuppliesPercent,
|
ShopSuppliesPercent = quote.ShopSuppliesPercent,
|
||||||
|
PricingBreakdownJson = JsonSerializer.Serialize(new QuotePricingBreakdownDto
|
||||||
|
{
|
||||||
|
MaterialCosts = quote.MaterialCosts,
|
||||||
|
LaborCosts = quote.LaborCosts,
|
||||||
|
EquipmentCosts = quote.EquipmentCosts,
|
||||||
|
ItemsSubtotal = quote.ItemsSubtotal,
|
||||||
|
OvenBatchCost = quote.OvenBatchCost,
|
||||||
|
OvenBatches = quote.OvenBatches,
|
||||||
|
OvenCycleMinutes = quote.OvenCycleMinutes ?? 0,
|
||||||
|
FacilityOverheadCost = quote.FacilityOverheadCost,
|
||||||
|
FacilityOverheadRatePerHour = quote.FacilityOverheadRatePerHour,
|
||||||
|
ShopSuppliesAmount = quote.ShopSuppliesAmount,
|
||||||
|
ShopSuppliesPercent = quote.ShopSuppliesPercent,
|
||||||
|
OverheadCosts = quote.OverheadAmount,
|
||||||
|
OverheadPercent = quote.OverheadPercent,
|
||||||
|
ProfitMargin = quote.ProfitMargin,
|
||||||
|
ProfitPercent = quote.ProfitPercent,
|
||||||
|
SubtotalBeforeDiscount = quote.SubTotal,
|
||||||
|
PricingTierDiscountAmount = quote.PricingTierDiscountAmount,
|
||||||
|
PricingTierDiscountPercent = quote.PricingTierDiscountPercent,
|
||||||
|
QuoteDiscountAmount = quote.QuoteDiscountAmount,
|
||||||
|
QuoteDiscountPercent = quote.QuoteDiscountPercent,
|
||||||
|
DiscountAmount = quote.DiscountAmount,
|
||||||
|
DiscountPercent = quote.DiscountPercent,
|
||||||
|
SubtotalAfterDiscount = quote.SubtotalAfterDiscount,
|
||||||
|
RushFee = quote.RushFee,
|
||||||
|
TaxAmount = quote.TaxAmount,
|
||||||
|
TaxPercent = quote.TaxPercent,
|
||||||
|
Total = quote.Total
|
||||||
|
}),
|
||||||
CustomerPO = quote.CustomerPO,
|
CustomerPO = quote.CustomerPO,
|
||||||
InternalNotes = quote.Notes, // Copy internal notes from quote
|
InternalNotes = quote.Notes, // Copy internal notes from quote
|
||||||
IsCustomerApproved = true,
|
IsCustomerApproved = true,
|
||||||
|
|||||||
@@ -911,7 +911,7 @@ public class ToolsController : Controller
|
|||||||
/// <c>CompanyId</c> provides the multi-tenant isolation that global query filters would
|
/// <c>CompanyId</c> provides the multi-tenant isolation that global query filters would
|
||||||
/// normally enforce for other entity types.
|
/// normally enforce for other entity types.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
// GET: Tools/GetShopWorkers - For randomizer wheel
|
// GET: Tools/GetShopWorkers - Returns active company users for randomizer wheel
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> GetShopWorkers()
|
public async Task<IActionResult> GetShopWorkers()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1219,7 +1219,6 @@ public static class HelpKnowledgeBase
|
|||||||
- [Accounts Payable](/Help/AccountsPayable)
|
- [Accounts Payable](/Help/AccountsPayable)
|
||||||
- [Equipment & Maintenance](/Help/Equipment)
|
- [Equipment & Maintenance](/Help/Equipment)
|
||||||
- [Vendors](/Help/Vendors)
|
- [Vendors](/Help/Vendors)
|
||||||
- [Shop Workers](/Help/ShopWorkers)
|
|
||||||
- [Reports](/Help/Reports)
|
- [Reports](/Help/Reports)
|
||||||
- [Settings](/Help/Settings)
|
- [Settings](/Help/Settings)
|
||||||
- [User Profile](/Help/UserProfile)
|
- [User Profile](/Help/UserProfile)
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ public class OnlineUserMiddleware
|
|||||||
{
|
{
|
||||||
await _next(context);
|
await _next(context);
|
||||||
|
|
||||||
|
// Skip AJAX/JSON responses — they are not page navigations and would
|
||||||
|
// cause the "current page" to show the polling endpoint (e.g. /InAppNotifications/Recent)
|
||||||
|
// rather than the actual page the user is on.
|
||||||
|
if (context.Response.ContentType?.Contains("application/json", StringComparison.OrdinalIgnoreCase) == true)
|
||||||
|
return;
|
||||||
|
|
||||||
// Only track authenticated, non-API, non-asset requests
|
// Only track authenticated, non-API, non-asset requests
|
||||||
if (!context.User.Identity?.IsAuthenticated ?? true) return;
|
if (!context.User.Identity?.IsAuthenticated ?? true) return;
|
||||||
var path = context.Request.Path.Value ?? string.Empty;
|
var path = context.Request.Path.Value ?? string.Empty;
|
||||||
|
|||||||
@@ -270,8 +270,7 @@ builder.Services.AddSingleton<IMapper>(sp =>
|
|||||||
cfg.AddProfile(new InventoryProfile());
|
cfg.AddProfile(new InventoryProfile());
|
||||||
cfg.AddProfile(new EquipmentProfile());
|
cfg.AddProfile(new EquipmentProfile());
|
||||||
cfg.AddProfile(new MaintenanceProfile());
|
cfg.AddProfile(new MaintenanceProfile());
|
||||||
cfg.AddProfile(new ShopWorkerProfile());
|
cfg.AddProfile(new CatalogProfile());
|
||||||
cfg.AddProfile(new CatalogProfile());
|
|
||||||
cfg.AddProfile(new VendorProfile());
|
cfg.AddProfile(new VendorProfile());
|
||||||
cfg.AddProfile(new LookupProfile());
|
cfg.AddProfile(new LookupProfile());
|
||||||
cfg.AddProfile(new AppointmentProfile());
|
cfg.AddProfile(new AppointmentProfile());
|
||||||
|
|||||||
@@ -232,4 +232,3 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,69 @@
|
|||||||
<span class="fw-semibold">Per-Company Breakdown</span>
|
<span class="fw-semibold">Per-Company Breakdown</span>
|
||||||
<span class="text-muted small">@Model.Rows.Count companies total</span>
|
<span class="text-muted small">@Model.Rows.Count companies total</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mobile-card-view">
|
||||||
|
<div class="mobile-card-list">
|
||||||
|
@foreach (var row in Model.Rows)
|
||||||
|
{
|
||||||
|
<div class="mobile-data-card">
|
||||||
|
<div class="mobile-card-header">
|
||||||
|
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%);">
|
||||||
|
<i class="bi bi-robot"></i>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-title">
|
||||||
|
<h6>@row.CompanyName @if (!row.IsActive) { <span class="badge bg-secondary ms-1">Inactive</span> }</h6>
|
||||||
|
<small><span class="badge bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle">@row.Plan</span></small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-body">
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Today</span>
|
||||||
|
<span class="mobile-card-value @(row.Today > 0 ? "fw-semibold" : "text-muted")">
|
||||||
|
@if (row.Today > 0) { @row.Today.ToString("N0") } else { <span>—</span> }
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">30 Days</span>
|
||||||
|
<span class="mobile-card-value @(row.Last30Days > 0 ? "fw-semibold" : "text-muted")">
|
||||||
|
@if (row.Last30Days > 0) { @row.Last30Days.ToString("N0") } else { <span>—</span> }
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">All Time</span>
|
||||||
|
<span class="mobile-card-value @(row.AllTime > 0 ? "" : "text-muted")">
|
||||||
|
@if (row.AllTime > 0) { @row.AllTime.ToString("N0") } else { <span>—</span> }
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@if (row.TopFeature != null)
|
||||||
|
{
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Top Feature</span>
|
||||||
|
<span class="mobile-card-value">
|
||||||
|
<i class="bi @FeatureIcon(row.TopFeature) me-1 text-muted"></i>@row.FeatureDisplayName(row.TopFeature)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Tier</span>
|
||||||
|
<span class="mobile-card-value"><span class="badge @row.TierBadgeClass">@row.UsageTier</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-footer">
|
||||||
|
<a asp-controller="Companies" asp-action="Details" asp-route-id="@row.CompanyId" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="bi bi-building me-1"></i>Company
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (!Model.Rows.Any())
|
||||||
|
{
|
||||||
|
<div class="text-center text-muted py-5">
|
||||||
|
<i class="bi bi-robot fs-1 d-block mb-2 opacity-25"></i>
|
||||||
|
No AI usage logged yet.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover mb-0 align-middle" id="aiUsageTable">
|
<table class="table table-hover mb-0 align-middle" id="aiUsageTable">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
|
|||||||
@@ -176,6 +176,60 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@if (Model.Items.Any())
|
@if (Model.Items.Any())
|
||||||
{
|
{
|
||||||
|
<div class="mobile-card-view">
|
||||||
|
<div class="mobile-card-list">
|
||||||
|
@foreach (var appointment in Model.Items)
|
||||||
|
{
|
||||||
|
<div class="mobile-data-card">
|
||||||
|
<div class="mobile-card-header">
|
||||||
|
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);">
|
||||||
|
<i class="bi bi-calendar-event"></i>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-title">
|
||||||
|
<h6>@appointment.Title</h6>
|
||||||
|
<small>@appointment.ScheduledStartTime.ToString("MMM dd, yyyy")<br />@(!appointment.IsAllDay ? $"{appointment.ScheduledStartTime:h:mm tt} – {appointment.ScheduledEndTime:h:mm tt}" : "All Day")</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-body">
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Status</span>
|
||||||
|
<span class="mobile-card-value">
|
||||||
|
<span class="badge bg-@appointment.StatusColorClass">@appointment.StatusDisplayName</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Type</span>
|
||||||
|
<span class="mobile-card-value">
|
||||||
|
<span class="badge bg-@appointment.TypeColorClass">@appointment.TypeDisplayName</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrEmpty(appointment.CustomerName))
|
||||||
|
{
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Customer</span>
|
||||||
|
<span class="mobile-card-value">@appointment.CustomerName</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrEmpty(appointment.AssignedWorkerName))
|
||||||
|
{
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Worker</span>
|
||||||
|
<span class="mobile-card-value">@appointment.AssignedWorkerName</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-footer">
|
||||||
|
<a asp-action="Details" asp-route-id="@appointment.Id" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-eye me-1"></i>View
|
||||||
|
</a>
|
||||||
|
<a asp-action="Edit" asp-route-id="@appointment.Id" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="bi bi-pencil me-1"></i>Edit
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover align-middle">
|
<table class="table table-hover align-middle">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -21,6 +21,64 @@
|
|||||||
|
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
|
<div class="mobile-card-view">
|
||||||
|
<div class="mobile-card-list">
|
||||||
|
@foreach (var br in Model)
|
||||||
|
{
|
||||||
|
<div class="mobile-data-card">
|
||||||
|
<div class="mobile-card-header">
|
||||||
|
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #14b8a6 0%, #0f766e 100%);">
|
||||||
|
<i class="bi bi-bank"></i>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-title">
|
||||||
|
<h6>@br.Account?.Name</h6>
|
||||||
|
<small>Statement: @br.StatementDate.ToString("MMM d, yyyy")</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-body">
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Status</span>
|
||||||
|
<span class="mobile-card-value">
|
||||||
|
@if (br.Status == BankReconciliationStatus.Completed)
|
||||||
|
{
|
||||||
|
<span class="badge bg-success">Completed</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="badge bg-warning text-dark">In Progress</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Ending Balance</span>
|
||||||
|
<span class="mobile-card-value fw-semibold">@br.EndingBalance.ToString("C")</span>
|
||||||
|
</div>
|
||||||
|
@if (br.CompletedAt.HasValue)
|
||||||
|
{
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Completed By</span>
|
||||||
|
<span class="mobile-card-value">@br.CompletedBy</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-footer">
|
||||||
|
@if (br.Status == BankReconciliationStatus.Completed)
|
||||||
|
{
|
||||||
|
<a asp-action="Report" asp-route-id="@br.Id" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="bi bi-file-earmark-text me-1"></i>Report
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a asp-action="Reconcile" asp-route-id="@br.Id" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-check2-square me-1"></i>Continue
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover mb-0">
|
<table class="table table-hover mb-0">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
|
|||||||
@@ -60,6 +60,59 @@
|
|||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
@if (active.Any())
|
@if (active.Any())
|
||||||
{
|
{
|
||||||
|
<div class="mobile-card-view">
|
||||||
|
<div class="mobile-card-list">
|
||||||
|
@foreach (var ban in active)
|
||||||
|
{
|
||||||
|
<div class="mobile-data-card">
|
||||||
|
<div class="mobile-card-header">
|
||||||
|
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #dc2626 0%, #991b1b 100%);">
|
||||||
|
<i class="bi bi-slash-circle"></i>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-title">
|
||||||
|
<h6 class="font-monospace">@ban.IpAddress</h6>
|
||||||
|
<small class="text-muted">@(ban.Reason ?? "No reason given")</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-body">
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Banned</span>
|
||||||
|
<span class="mobile-card-value">@ban.BannedAt.ToString("MMM d, yyyy HH:mm")</span>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Expires</span>
|
||||||
|
<span class="mobile-card-value">
|
||||||
|
@if (ban.ExpiresAt.HasValue)
|
||||||
|
{
|
||||||
|
<span class="badge bg-warning text-dark">@ban.ExpiresAt.Value.ToString("MMM d, yyyy")</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="badge bg-secondary">Permanent</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-footer">
|
||||||
|
<form asp-action="Lift" asp-route-id="@ban.Id" method="post" class="d-inline"
|
||||||
|
onsubmit="return confirm('Lift the ban on @ban.IpAddress?')">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-success">
|
||||||
|
<i class="bi bi-check-circle me-1"></i>Lift
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form asp-action="Delete" asp-route-id="@ban.Id" method="post" class="d-inline"
|
||||||
|
onsubmit="return confirm('Delete ban record for @ban.IpAddress?')">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover mb-0">
|
<table class="table table-hover mb-0">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
@@ -130,6 +183,55 @@
|
|||||||
<h6 class="mb-0 text-muted"><i class="bi bi-clock-history"></i> Lifted / Expired Bans</h6>
|
<h6 class="mb-0 text-muted"><i class="bi bi-clock-history"></i> Lifted / Expired Bans</h6>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
|
<div class="mobile-card-view">
|
||||||
|
<div class="mobile-card-list">
|
||||||
|
@foreach (var ban in inactive)
|
||||||
|
{
|
||||||
|
<div class="mobile-data-card">
|
||||||
|
<div class="mobile-card-header">
|
||||||
|
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%);">
|
||||||
|
<i class="bi bi-clock-history"></i>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-title">
|
||||||
|
<h6 class="font-monospace">@ban.IpAddress</h6>
|
||||||
|
<small>
|
||||||
|
@if (!ban.IsActive)
|
||||||
|
{
|
||||||
|
<span class="badge bg-success">Lifted</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="badge bg-secondary">Expired</span>
|
||||||
|
}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-body">
|
||||||
|
@if (!string.IsNullOrEmpty(ban.Reason))
|
||||||
|
{
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Reason</span>
|
||||||
|
<span class="mobile-card-value text-muted">@ban.Reason</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Banned</span>
|
||||||
|
<span class="mobile-card-value text-muted">@ban.BannedAt.ToString("MMM d, yyyy")</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-footer">
|
||||||
|
<form asp-action="Delete" asp-route-id="@ban.Id" method="post" class="d-inline"
|
||||||
|
onsubmit="return confirm('Delete ban record for @ban.IpAddress?')">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||||
|
<i class="bi bi-trash me-1"></i>Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-sm table-hover mb-0">
|
<table class="table table-sm table-hover mb-0">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
ViewData["Title"] = "Edit Bill";
|
ViewData["Title"] = "Edit Bill";
|
||||||
ViewData["PageIcon"] = "bi-pencil-square";
|
ViewData["PageIcon"] = "bi-pencil-square";
|
||||||
ViewData["PageHelpTitle"] = "Edit Bill";
|
ViewData["PageHelpTitle"] = "Edit Bill";
|
||||||
ViewData["PageHelpContent"] = "Bills can only be edited while in Draft status. Once marked Open, they are locked — Void the bill and recreate it if corrections are needed after confirmation.";
|
ViewData["PageHelpContent"] = "Bills can only be edited while in Draft status. Once marked Open, they are locked — Void the bill and recreate it if corrections are needed after confirmation.";
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="d-flex justify-content-start mb-4">
|
<div class="d-flex justify-content-start mb-4">
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<a tabindex="0" class="help-icon" role="button"
|
<a tabindex="0" class="help-icon" role="button"
|
||||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||||
data-bs-title="Bill Details"
|
data-bs-title="Bill Details"
|
||||||
data-bs-content="Vendor: who you're paying. AP Account: the liability account this bill posts to (e.g. Accounts Payable). Bill Date: date on the vendor's invoice. Due Date: when payment is due — drives overdue status. Vendor Invoice #: the vendor's own reference number for reconciliation.">
|
data-bs-content="Vendor: who you're paying. AP Account: the liability account this bill posts to (e.g. Accounts Payable). Bill Date: date on the vendor's invoice. Due Date: when payment is due — drives overdue status. Vendor Invoice #: the vendor's own reference number for reconciliation.">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -34,8 +34,8 @@
|
|||||||
<label asp-for="VendorId" class="form-label fw-medium">Vendor <span class="text-danger">*</span></label>
|
<label asp-for="VendorId" class="form-label fw-medium">Vendor <span class="text-danger">*</span></label>
|
||||||
<select asp-for="VendorId" asp-items="ViewBag.Vendors" class="form-select"
|
<select asp-for="VendorId" asp-items="ViewBag.Vendors" class="form-select"
|
||||||
data-quick-add-url="/Vendors/Create" data-quick-add-title="Add New Vendor">
|
data-quick-add-url="/Vendors/Create" data-quick-add-title="Add New Vendor">
|
||||||
<option value="">— Select Vendor —</option>
|
<option value="">— Select Vendor —</option>
|
||||||
<option value="__new__">+ Add New Vendor…</option>
|
<option value="__new__">+ Add New Vendor…</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
}
|
}
|
||||||
<input type="file" name="receiptFile" id="receiptFile" class="form-control"
|
<input type="file" name="receiptFile" id="receiptFile" class="form-control"
|
||||||
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf" />
|
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf" />
|
||||||
<div class="form-text">JPG, PNG, GIF, WebP, or PDF — up to 10 MB.</div>
|
<div class="form-text">JPG, PNG, GIF, WebP, or PDF — up to 10 MB.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
<a tabindex="0" class="help-icon" role="button"
|
<a tabindex="0" class="help-icon" role="button"
|
||||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||||
data-bs-title="Line Items"
|
data-bs-title="Line Items"
|
||||||
data-bs-content="Each line maps to an expense account (e.g. Supplies, Materials, Subcontractors). Optionally link a line to a Job to track costs against specific work orders. Qty × Unit Price = Amount. Use multiple lines to split one bill across different expense categories.">
|
data-bs-content="Each line maps to an expense account (e.g. Supplies, Materials, Subcontractors). Optionally link a line to a Job to track costs against specific work orders. Qty × Unit Price = Amount. Use multiple lines to split one bill across different expense categories.">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
<a tabindex="0" class="help-icon" role="button"
|
<a tabindex="0" class="help-icon" role="button"
|
||||||
data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus"
|
data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus"
|
||||||
data-bs-title="Bill Summary"
|
data-bs-title="Bill Summary"
|
||||||
data-bs-content="Tax % is applied to the line-item subtotal. The resulting Total is the full amount owed to the vendor. Partial payments are allowed — each payment recorded reduces the balance due until the bill is fully paid.">
|
data-bs-content="Tax % is applied to the line-item subtotal. The resulting Total is the full amount owed to the vendor. Partial payments are allowed — each payment recorded reduces the balance due until the bill is fully paid.">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,7 +171,7 @@
|
|||||||
<tr class="line-item-row">
|
<tr class="line-item-row">
|
||||||
<td>
|
<td>
|
||||||
<select class="form-select form-select-sm account-select" name="LineItems[INDEX].AccountId" required>
|
<select class="form-select form-select-sm account-select" name="LineItems[INDEX].AccountId" required>
|
||||||
<option value="">— Account —</option>
|
<option value="">— Account —</option>
|
||||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.ExpenseAccounts)
|
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.ExpenseAccounts)
|
||||||
{
|
{
|
||||||
<option value="@item.Value">@item.Text</option>
|
<option value="@item.Value">@item.Text</option>
|
||||||
@@ -181,7 +181,7 @@
|
|||||||
<td><input type="text" class="form-control form-control-sm" name="LineItems[INDEX].Description" placeholder="Description" /></td>
|
<td><input type="text" class="form-control form-control-sm" name="LineItems[INDEX].Description" placeholder="Description" /></td>
|
||||||
<td>
|
<td>
|
||||||
<select class="form-select form-select-sm" name="LineItems[INDEX].JobId">
|
<select class="form-select form-select-sm" name="LineItems[INDEX].JobId">
|
||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.Jobs)
|
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.Jobs)
|
||||||
{
|
{
|
||||||
<option value="@item.Value">@item.Text</option>
|
<option value="@item.Value">@item.Text</option>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@model PowderCoating.Application.DTOs.Company.CompanySettingsDto
|
@model PowderCoating.Application.DTOs.Company.CompanySettingsDto
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Company Settings";
|
ViewData["Title"] = "Company Settings";
|
||||||
ViewData["PageIcon"] = "bi-building";
|
ViewData["PageIcon"] = "bi-building";
|
||||||
@@ -375,6 +375,18 @@
|
|||||||
<input type="number" step="0.01" class="form-control" id="standardLaborRate" name="StandardLaborRate" value="@(Model.OperatingCosts?.StandardLaborRate ?? 0)" min="0" max="10000" required>
|
<input type="number" step="0.01" class="form-control" id="standardLaborRate" name="StandardLaborRate" value="@(Model.OperatingCosts?.StandardLaborRate ?? 0)" min="0" max="10000" required>
|
||||||
<span class="input-group-text">/hr</span>
|
<span class="input-group-text">/hr</span>
|
||||||
</div>
|
</div>
|
||||||
|
<small class="text-muted">Billing rate used in quotes and pricing</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="laborCostPerHour" class="form-label">Shop Labor Cost Rate</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">$</span>
|
||||||
|
<input type="number" step="0.01" class="form-control" id="laborCostPerHour" name="LaborCostPerHour" value="@(Model.OperatingCosts?.LaborCostPerHour?.ToString() ?? "")" min="0" max="10000" placeholder="@(((Model.OperatingCosts?.StandardLaborRate ?? 0) * 0.20m).ToString("0.00"))">
|
||||||
|
<span class="input-group-text">/hr</span>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">Actual wage cost for job costing & profit display only — never shown to customers. Leave blank to default to 20% of billing rate.</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
@@ -516,35 +528,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Role-Based Labor Rates -->
|
|
||||||
<h6 class="border-bottom pb-2 mb-3 mt-4">Role-Based Labor Cost Rates
|
|
||||||
<a tabindex="0" class="help-icon" role="button"
|
|
||||||
data-bs-toggle="popover" data-bs-placement="right"
|
|
||||||
data-bs-title="Role-Based Labor Cost Rates"
|
|
||||||
data-bs-content="Set an optional cost rate per worker role for job profitability calculations. These are your <strong>internal cost rates</strong> (what you pay), not what you bill customers. If a rate is left blank, the <strong>Standard Labor Rate</strong> above is used as the fallback.">
|
|
||||||
<i class="bi bi-question-circle"></i>
|
|
||||||
</a>
|
|
||||||
</h6>
|
|
||||||
<p class="text-muted small">Used for job costing only — not shown to customers. Leave blank to use the Standard Labor Rate.</p>
|
|
||||||
<div class="table-responsive mb-3">
|
|
||||||
<table class="table table-sm align-middle" id="roleCostTable">
|
|
||||||
<thead class="table-light">
|
|
||||||
<tr>
|
|
||||||
<th>Role</th>
|
|
||||||
<th style="width:180px;">Cost Rate / hr</th>
|
|
||||||
<th style="width:140px;" class="text-muted small">Fallback if blank</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="roleCostBody">
|
|
||||||
<tr><td colspan="3" class="text-center text-muted py-2"><div class="spinner-border spinner-border-sm me-2"></div>Loading...</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-sm btn-primary" onclick="saveRoleCosts()">
|
|
||||||
<i class="bi bi-floppy me-1"></i>Save Labor Rates
|
|
||||||
</button>
|
|
||||||
<span id="roleCostSaveStatus" class="ms-2 small"></span>
|
|
||||||
|
|
||||||
<!-- Pricing & Overhead -->
|
<!-- Pricing & Overhead -->
|
||||||
<h6 class="border-bottom pb-2 mb-3 mt-4">Pricing & Profit
|
<h6 class="border-bottom pb-2 mb-3 mt-4">Pricing & Profit
|
||||||
<a tabindex="0" class="help-icon" role="button"
|
<a tabindex="0" class="help-icon" role="button"
|
||||||
@@ -2949,76 +2932,6 @@
|
|||||||
loadOvenCosts();
|
loadOvenCosts();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reload role costs whenever the Operating Costs tab is shown
|
|
||||||
document.getElementById('operating-costs-tab')?.addEventListener('shown.bs.tab', () => {
|
|
||||||
loadRoleCosts();
|
|
||||||
});
|
|
||||||
|
|
||||||
// If Equipment Profile tab is already active on page load, load immediately
|
|
||||||
if (document.getElementById('quoting-calibration')?.classList.contains('show')) {
|
|
||||||
loadOvenCosts();
|
|
||||||
}
|
|
||||||
|
|
||||||
// If Operating Costs tab is already active on page load, load role costs immediately
|
|
||||||
if (document.getElementById('operating-costs')?.classList.contains('show')) {
|
|
||||||
loadRoleCosts();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Role-Based Labor Cost Rates ────────────────────────────────────────
|
|
||||||
const ROLE_NAMES = ['General Labor','Sandblaster','Coater','Masker','Quality Control','Oven Operator','Supervisor','Maintenance'];
|
|
||||||
|
|
||||||
async function loadRoleCosts() {
|
|
||||||
const resp = await fetch('/CompanySettings/GetRoleCosts');
|
|
||||||
const saved = await resp.json(); // [{role, hourlyRate}]
|
|
||||||
const rateMap = {};
|
|
||||||
saved.forEach(r => rateMap[r.role] = r.hourlyRate);
|
|
||||||
|
|
||||||
const fallbackEl = document.getElementById('standardLaborRate');
|
|
||||||
const fallback = fallbackEl ? `$${parseFloat(fallbackEl.value || 0).toFixed(2)}/hr` : 'Standard Rate';
|
|
||||||
|
|
||||||
const tbody = document.getElementById('roleCostBody');
|
|
||||||
tbody.innerHTML = ROLE_NAMES.map((name, i) => `
|
|
||||||
<tr>
|
|
||||||
<td><span class="badge bg-secondary">${name}</span></td>
|
|
||||||
<td>
|
|
||||||
<div class="input-group input-group-sm">
|
|
||||||
<span class="input-group-text">$</span>
|
|
||||||
<input type="number" step="0.01" min="0" max="999"
|
|
||||||
class="form-control role-cost-input"
|
|
||||||
data-role="${i}"
|
|
||||||
value="${rateMap[i] > 0 ? rateMap[i] : ''}"
|
|
||||||
placeholder="(use default)">
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="text-muted small">${fallback}</td>
|
|
||||||
</tr>`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveRoleCosts() {
|
|
||||||
const inputs = document.querySelectorAll('.role-cost-input');
|
|
||||||
const rates = Array.from(inputs).map(el => ({
|
|
||||||
role: parseInt(el.dataset.role),
|
|
||||||
hourlyRate: parseFloat(el.value) || 0
|
|
||||||
}));
|
|
||||||
const statusEl = document.getElementById('roleCostSaveStatus');
|
|
||||||
statusEl.textContent = 'Saving...';
|
|
||||||
statusEl.className = 'ms-2 small text-muted';
|
|
||||||
const resp = await fetch('/CompanySettings/SaveRoleCosts', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', 'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '' },
|
|
||||||
body: JSON.stringify(rates)
|
|
||||||
});
|
|
||||||
const result = await resp.json();
|
|
||||||
if (result.success) {
|
|
||||||
statusEl.textContent = '✓ Saved';
|
|
||||||
statusEl.className = 'ms-2 small text-success';
|
|
||||||
setTimeout(() => statusEl.textContent = '', 3000);
|
|
||||||
} else {
|
|
||||||
statusEl.textContent = result.message || 'Error saving';
|
|
||||||
statusEl.className = 'ms-2 small text-danger';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Quote PDF Template ──────────────────────────────────────────────
|
// ── Quote PDF Template ──────────────────────────────────────────────
|
||||||
function syncColorPicker(hex) {
|
function syncColorPicker(hex) {
|
||||||
if (/^#[0-9A-Fa-f]{6}$/.test(hex)) {
|
if (/^#[0-9A-Fa-f]{6}$/.test(hex)) {
|
||||||
|
|||||||
@@ -106,6 +106,16 @@
|
|||||||
<input asp-for="Position" class="form-control" />
|
<input asp-for="Position" class="form-control" />
|
||||||
<span asp-validation-for="Position" class="text-danger"></span>
|
<span asp-validation-for="Position" class="text-danger"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label asp-for="LaborCostPerHour" class="form-label">Labor Cost Rate</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">$</span>
|
||||||
|
<input asp-for="LaborCostPerHour" type="number" step="0.01" min="0" max="10000" class="form-control" placeholder="Use company default" />
|
||||||
|
<span class="input-group-text">/hr</span>
|
||||||
|
</div>
|
||||||
|
<span asp-validation-for="LaborCostPerHour" class="text-danger"></span>
|
||||||
|
<small class="text-muted">Used for internal job costing only — never shown to customers. Overrides the company default when set. Leave blank to use the shop-wide rate.</small>
|
||||||
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label asp-for="HireDate" class="form-label">Hire Date</label>
|
<label asp-for="HireDate" class="form-label">Hire Date</label>
|
||||||
<input asp-for="HireDate" class="form-control" type="date" />
|
<input asp-for="HireDate" class="form-control" type="date" />
|
||||||
|
|||||||
@@ -101,6 +101,73 @@
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
<div class="mobile-card-view">
|
||||||
|
<div class="mobile-card-list">
|
||||||
|
@foreach (var m in Model)
|
||||||
|
{
|
||||||
|
var expired2 = m.ExpiryDate.HasValue && m.ExpiryDate.Value < DateTime.UtcNow
|
||||||
|
&& m.Status != CreditMemoStatus.FullyApplied
|
||||||
|
&& m.Status != CreditMemoStatus.Voided;
|
||||||
|
var (cmBadge, cmLabel) = m.Status switch
|
||||||
|
{
|
||||||
|
CreditMemoStatus.Active => ("bg-success-subtle text-success", "Active"),
|
||||||
|
CreditMemoStatus.PartiallyApplied => ("bg-warning-subtle text-warning", "Partial"),
|
||||||
|
CreditMemoStatus.FullyApplied => ("bg-secondary-subtle text-secondary", "Applied"),
|
||||||
|
CreditMemoStatus.Voided => ("bg-danger-subtle text-danger", "Voided"),
|
||||||
|
_ => ("bg-secondary-subtle text-secondary", m.Status.ToString())
|
||||||
|
};
|
||||||
|
var cmCustomer = string.IsNullOrWhiteSpace(m.Customer?.CompanyName)
|
||||||
|
? $"{m.Customer?.ContactFirstName} {m.Customer?.ContactLastName}".Trim()
|
||||||
|
: m.Customer!.CompanyName;
|
||||||
|
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", new { id = m.Id })'">
|
||||||
|
<div class="mobile-card-header">
|
||||||
|
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);">
|
||||||
|
<i class="bi bi-journal-minus"></i>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-title">
|
||||||
|
<h6>@m.MemoNumber</h6>
|
||||||
|
<small>@cmCustomer</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-body">
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Status</span>
|
||||||
|
<span class="mobile-card-value"><span class="badge @cmBadge">@cmLabel</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Amount</span>
|
||||||
|
<span class="mobile-card-value">@m.Amount.ToString("C")</span>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Remaining</span>
|
||||||
|
<span class="mobile-card-value @(m.RemainingBalance > 0 && m.Status != CreditMemoStatus.Voided ? "text-success fw-semibold" : "text-muted")">
|
||||||
|
@m.RemainingBalance.ToString("C")
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Issued</span>
|
||||||
|
<span class="mobile-card-value">@m.IssueDate.ToLocalTime().ToString("MM/dd/yy")</span>
|
||||||
|
</div>
|
||||||
|
@if (m.ExpiryDate.HasValue)
|
||||||
|
{
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Expires</span>
|
||||||
|
<span class="mobile-card-value @(expired2 ? "text-danger fw-semibold" : "")">
|
||||||
|
@m.ExpiryDate.Value.ToLocalTime().ToString("MM/dd/yy")
|
||||||
|
@if (expired2) { <small>(Expired)</small> }
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-footer">
|
||||||
|
<a asp-action="Details" asp-route-id="@m.Id" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
|
||||||
|
Details
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover align-middle mb-0">
|
<table class="table table-hover align-middle mb-0">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
|
|||||||
@@ -118,6 +118,63 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
<div class="mobile-card-view">
|
||||||
|
<div class="mobile-card-list">
|
||||||
|
@foreach (var a in Model)
|
||||||
|
{
|
||||||
|
var fd = a.AccumulatedDepreciation >= (a.PurchaseCost - a.SalvageValue);
|
||||||
|
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", new { id = a.Id })'">
|
||||||
|
<div class="mobile-card-header">
|
||||||
|
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%);">
|
||||||
|
<i class="bi bi-building-gear"></i>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-title">
|
||||||
|
<h6>@a.Name</h6>
|
||||||
|
<small>Purchased @a.PurchaseDate.ToLocalTime().ToString("MM/dd/yyyy")</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-body">
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Status</span>
|
||||||
|
<span class="mobile-card-value">
|
||||||
|
@if (a.IsDisposed)
|
||||||
|
{
|
||||||
|
<span class="badge bg-secondary">Disposed</span>
|
||||||
|
}
|
||||||
|
else if (fd)
|
||||||
|
{
|
||||||
|
<span class="badge bg-light text-dark border">Fully Depreciated</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="badge bg-success">Active</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Cost</span>
|
||||||
|
<span class="mobile-card-value">@a.PurchaseCost.ToString("C")</span>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Book Value</span>
|
||||||
|
<span class="mobile-card-value @(a.BookValue <= 0 ? "text-muted" : "text-success fw-semibold")">
|
||||||
|
@a.BookValue.ToString("C")
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Monthly Depr.</span>
|
||||||
|
<span class="mobile-card-value">@a.MonthlyDepreciation.ToString("C")</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-footer">
|
||||||
|
<a asp-action="Details" asp-route-id="@a.Id" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
|
||||||
|
<i class="bi bi-eye me-1"></i>View
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover align-middle mb-0">
|
<table class="table table-hover align-middle mb-0">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
@model PowderCoating.Application.DTOs.GiftCertificate.BulkCreateGiftCertificateDto
|
||||||
|
@using PowderCoating.Core.Enums
|
||||||
|
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Bulk Create Gift Certificates";
|
||||||
|
ViewData["PageIcon"] = "bi-gift";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white border-bottom py-3">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="bi bi-collection me-2 text-primary"></i>Bulk Gift Certificate Generator
|
||||||
|
</h5>
|
||||||
|
<p class="text-muted small mb-0 mt-1">
|
||||||
|
Create a batch of certificates for car shows, events, or promotions. All certificates will have the same
|
||||||
|
face value and be generated with sequential codes ready to print.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<form asp-action="BulkCreate" method="post">
|
||||||
|
<div asp-validation-summary="ModelOnly" class="alert alert-danger" role="alert"></div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label asp-for="Quantity" class="form-label fw-semibold">
|
||||||
|
<i class="bi bi-123 me-1 text-muted"></i>@Html.DisplayNameFor(m => m.Quantity)
|
||||||
|
</label>
|
||||||
|
<input asp-for="Quantity" type="number" class="form-control form-control-lg"
|
||||||
|
min="1" max="500" placeholder="25" />
|
||||||
|
<span asp-validation-for="Quantity" class="text-danger small"></span>
|
||||||
|
<div class="form-text">Max 500 per batch.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-7">
|
||||||
|
<label asp-for="Amount" class="form-label fw-semibold">
|
||||||
|
<i class="bi bi-currency-dollar me-1 text-muted"></i>@Html.DisplayNameFor(m => m.Amount)
|
||||||
|
</label>
|
||||||
|
<div class="input-group input-group-lg">
|
||||||
|
<span class="input-group-text">$</span>
|
||||||
|
<input asp-for="Amount" type="number" class="form-control"
|
||||||
|
min="1" max="9999.99" step="0.01" placeholder="50.00" />
|
||||||
|
</div>
|
||||||
|
<span asp-validation-for="Amount" class="text-danger small"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<label asp-for="IssuedReason" class="form-label fw-semibold">
|
||||||
|
<i class="bi bi-tag me-1 text-muted"></i>@Html.DisplayNameFor(m => m.IssuedReason)
|
||||||
|
</label>
|
||||||
|
<select asp-for="IssuedReason" class="form-select">
|
||||||
|
@foreach (var reason in Enum.GetValues<GiftCertificateIssuedReason>())
|
||||||
|
{
|
||||||
|
<option value="@reason">@reason</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<span asp-validation-for="IssuedReason" class="text-danger small"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<label asp-for="ExpiryDate" class="form-label fw-semibold">
|
||||||
|
<i class="bi bi-calendar-x me-1 text-muted"></i>@Html.DisplayNameFor(m => m.ExpiryDate)
|
||||||
|
</label>
|
||||||
|
<input asp-for="ExpiryDate" type="date" class="form-control" />
|
||||||
|
<span asp-validation-for="ExpiryDate" class="text-danger small"></span>
|
||||||
|
<div class="form-text">Leave blank for no expiration.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<label asp-for="Notes" class="form-label fw-semibold">
|
||||||
|
<i class="bi bi-chat-left-text me-1 text-muted"></i>@Html.DisplayNameFor(m => m.Notes)
|
||||||
|
</label>
|
||||||
|
<textarea asp-for="Notes" class="form-control" rows="2"
|
||||||
|
placeholder="e.g. Awarded at the 2026 Summer Car Show — thanks for attending!"></textarea>
|
||||||
|
<span asp-validation-for="Notes" class="text-danger small"></span>
|
||||||
|
<div class="form-text">Printed on every certificate in the batch.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview summary -->
|
||||||
|
<div id="batchPreview" class="alert alert-primary mt-4 mb-0" style="display:none">
|
||||||
|
<i class="bi bi-info-circle me-2"></i>
|
||||||
|
You are about to create <strong id="prevQty"></strong> certificates worth
|
||||||
|
<strong id="prevAmt"></strong> each — total face value
|
||||||
|
<strong id="prevTotal"></strong>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-4 pt-3 border-top">
|
||||||
|
<a asp-action="Index" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Cancel
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg" id="submitBtn">
|
||||||
|
<i class="bi bi-plus-circle me-2"></i>Create Certificates
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script src="~/js/gift-certificate-bulk.js" asp-append-version="true"></script>
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
@model List<PowderCoating.Core.Entities.GiftCertificate>
|
||||||
|
@using PowderCoating.Core.Enums
|
||||||
|
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Batch Gift Certificates";
|
||||||
|
ViewData["PageIcon"] = "bi-gift";
|
||||||
|
var batchId = Model.FirstOrDefault()?.BatchId ?? Guid.Empty;
|
||||||
|
var count = Model.Count;
|
||||||
|
var amount = Model.FirstOrDefault()?.OriginalAmount ?? 0m;
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="alert alert-success alert-permanent mb-4">
|
||||||
|
<i class="bi bi-check-circle-fill me-2"></i>
|
||||||
|
<strong>@count gift certificates created</strong> — each worth @amount.ToString("C").
|
||||||
|
Download the PDF below to print the full batch. This page is bookmarkable — you can return here any time to re-download.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border-0 shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-white border-bottom d-flex justify-content-between align-items-center py-3">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="bi bi-collection me-2 text-primary"></i>Batch Certificates (@count)
|
||||||
|
<span class="text-muted small fw-normal ms-2 font-monospace">@batchId.ToString("N")[..8]…</span>
|
||||||
|
</h5>
|
||||||
|
<a asp-action="BatchDownloadPdf" asp-route-batchId="@batchId" class="btn btn-primary">
|
||||||
|
<i class="bi bi-file-pdf me-2"></i>Download All as PDF
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="mobile-card-view">
|
||||||
|
<div class="mobile-card-list">
|
||||||
|
@foreach (var cert in Model)
|
||||||
|
{
|
||||||
|
<div class="mobile-data-card">
|
||||||
|
<div class="mobile-card-header">
|
||||||
|
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #10b981 0%, #059669 100%);">
|
||||||
|
<i class="bi bi-gift"></i>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-title">
|
||||||
|
<h6 class="font-monospace">@cert.CertificateCode</h6>
|
||||||
|
<small>@cert.OriginalAmount.ToString("C")</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-body">
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Issued</span>
|
||||||
|
<span class="mobile-card-value">@cert.IssueDate.ToLocalTime().ToString("MMM d, yyyy")</span>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Expiry</span>
|
||||||
|
<span class="mobile-card-value">
|
||||||
|
@if (cert.ExpiryDate.HasValue) { @cert.ExpiryDate.Value.ToLocalTime().ToString("MMM d, yyyy") } else { <span class="text-muted">—</span> }
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Status</span>
|
||||||
|
<span class="mobile-card-value"><span class="badge bg-success">Active</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-footer">
|
||||||
|
<a asp-action="Details" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="bi bi-eye me-1"></i>View
|
||||||
|
</a>
|
||||||
|
<a asp-action="DownloadPdf" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="bi bi-file-pdf me-1"></i>PDF
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th class="ps-3">Certificate Code</th>
|
||||||
|
<th>Face Value</th>
|
||||||
|
<th>Issued</th>
|
||||||
|
<th>Expiry</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var cert in Model)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td class="ps-3 fw-semibold font-monospace">@cert.CertificateCode</td>
|
||||||
|
<td>@cert.OriginalAmount.ToString("C")</td>
|
||||||
|
<td>@cert.IssueDate.ToLocalTime().ToString("MMM d, yyyy")</td>
|
||||||
|
<td>
|
||||||
|
@(cert.ExpiryDate.HasValue
|
||||||
|
? cert.ExpiryDate.Value.ToLocalTime().ToString("MMM d, yyyy")
|
||||||
|
: "—")
|
||||||
|
</td>
|
||||||
|
<td><span class="badge bg-success">Active</span></td>
|
||||||
|
<td class="text-end">
|
||||||
|
<a asp-action="Details" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-secondary" title="View details">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
<a asp-action="DownloadPdf" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-secondary" title="Download single PDF">
|
||||||
|
<i class="bi bi-file-pdf"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-white border-top d-flex justify-content-between align-items-center py-3">
|
||||||
|
<a asp-action="Index" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Back to Gift Certificates
|
||||||
|
</a>
|
||||||
|
<a asp-action="BatchDownloadPdf" asp-route-batchId="@batchId" class="btn btn-primary">
|
||||||
|
<i class="bi bi-printer me-2"></i>Print Batch PDF (@count pages)
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="alert alert-@statusClass alert-permanent d-flex align-items-center mb-4">
|
<div class="alert alert-@statusClass d-flex align-items-center mb-4">
|
||||||
<i class="bi bi-gift me-2" style="font-size:1.4rem;"></i>
|
<i class="bi bi-gift me-2" style="font-size:1.4rem;"></i>
|
||||||
<div>
|
<div>
|
||||||
<strong>@statusLabel</strong>
|
<strong>@statusLabel</strong>
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
}
|
}
|
||||||
@if (Model.ExpiryDate.HasValue)
|
@if (Model.ExpiryDate.HasValue)
|
||||||
{
|
{
|
||||||
<span class="ms-2 small">· Expires @Model.ExpiryDate.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MMMM d, yyyy")</span>
|
<span class="ms-2 small">· Expires @Model.ExpiryDate.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MMMM d, yyyy")</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,10 +7,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<p class="text-muted mb-0">@ViewBag.TotalActive active certificates — @((ViewBag.TotalValue as decimal? ?? 0m).ToString("C")) outstanding value</p>
|
<p class="text-muted mb-0">@ViewBag.TotalActive active certificates — @((ViewBag.TotalValue as decimal? ?? 0m).ToString("C")) outstanding value</p>
|
||||||
<a asp-action="Create" class="btn btn-primary">
|
<div class="d-flex gap-2">
|
||||||
<i class="bi bi-plus-circle me-2"></i>New Certificate
|
<a asp-action="BulkCreate" class="btn btn-outline-primary">
|
||||||
</a>
|
<i class="bi bi-collection me-2"></i>Bulk Create
|
||||||
|
</a>
|
||||||
|
<a asp-action="Create" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus-circle me-2"></i>New Certificate
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
@@ -52,6 +57,73 @@ else
|
|||||||
{
|
{
|
||||||
<div class="card border-0 shadow-sm">
|
<div class="card border-0 shadow-sm">
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
|
<div class="mobile-card-view">
|
||||||
|
<div class="mobile-card-list">
|
||||||
|
@foreach (var cert in Model)
|
||||||
|
{
|
||||||
|
var (gcBadge, gcLabel) = cert.Status switch
|
||||||
|
{
|
||||||
|
GiftCertificateStatus.Active => ("bg-success", "Active"),
|
||||||
|
GiftCertificateStatus.PartiallyRedeemed => ("bg-info text-dark", "Partial"),
|
||||||
|
GiftCertificateStatus.FullyRedeemed => ("bg-secondary", "Used"),
|
||||||
|
GiftCertificateStatus.Expired => ("bg-warning text-dark", "Expired"),
|
||||||
|
GiftCertificateStatus.Voided => ("bg-danger", "Voided"),
|
||||||
|
_ => ("bg-secondary", cert.Status.ToString())
|
||||||
|
};
|
||||||
|
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", new { id = cert.Id })'">
|
||||||
|
<div class="mobile-card-header">
|
||||||
|
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #a855f7 0%, #7c3aed 100%);">
|
||||||
|
<i class="bi bi-gift"></i>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-title">
|
||||||
|
<h6 class="font-monospace">@cert.CertificateCode</h6>
|
||||||
|
<small>@(cert.RecipientName ?? cert.RecipientEmail ?? "No recipient")</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-body">
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Status</span>
|
||||||
|
<span class="mobile-card-value"><span class="badge @gcBadge">@gcLabel</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Face Value</span>
|
||||||
|
<span class="mobile-card-value">@cert.OriginalAmount.ToString("C")</span>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Remaining</span>
|
||||||
|
<span class="mobile-card-value @(cert.RemainingBalance > 0 ? "text-success fw-semibold" : "text-muted")">
|
||||||
|
@cert.RemainingBalance.ToString("C")
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Issued</span>
|
||||||
|
<span class="mobile-card-value">@cert.IssueDate.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd/yy")</span>
|
||||||
|
</div>
|
||||||
|
@if (cert.ExpiryDate.HasValue)
|
||||||
|
{
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Expires</span>
|
||||||
|
<span class="mobile-card-value @(cert.ExpiryDate.Value < DateTime.Now ? "text-danger" : "")">
|
||||||
|
@cert.ExpiryDate.Value.ToString("MM/dd/yy")
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-footer">
|
||||||
|
<a asp-action="Details" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
|
||||||
|
<i class="bi bi-eye me-1"></i>View
|
||||||
|
</a>
|
||||||
|
@if (cert.BatchId.HasValue)
|
||||||
|
{
|
||||||
|
<a asp-action="BulkResult" asp-route-batchId="@cert.BatchId" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
|
||||||
|
<i class="bi bi-collection me-1"></i>Batch
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover mb-0">
|
<table class="table table-hover mb-0">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
@@ -75,6 +147,14 @@ else
|
|||||||
<a asp-action="Details" asp-route-id="@cert.Id" class="fw-semibold text-decoration-none font-monospace">
|
<a asp-action="Details" asp-route-id="@cert.Id" class="fw-semibold text-decoration-none font-monospace">
|
||||||
@cert.CertificateCode
|
@cert.CertificateCode
|
||||||
</a>
|
</a>
|
||||||
|
@if (cert.BatchId.HasValue)
|
||||||
|
{
|
||||||
|
<a asp-action="BulkResult" asp-route-batchId="@cert.BatchId"
|
||||||
|
class="badge bg-primary-subtle text-primary text-decoration-none ms-1"
|
||||||
|
title="View & download batch">
|
||||||
|
<i class="bi bi-collection me-1"></i>Batch
|
||||||
|
</a>
|
||||||
|
}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@if (!string.IsNullOrEmpty(cert.RecipientName))
|
@if (!string.IsNullOrEmpty(cert.RecipientName))
|
||||||
@@ -83,7 +163,7 @@ else
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<span class="text-muted">—</span>
|
<span class="text-muted">—</span>
|
||||||
}
|
}
|
||||||
@if (!string.IsNullOrEmpty(cert.RecipientEmail))
|
@if (!string.IsNullOrEmpty(cert.RecipientEmail))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -189,22 +189,6 @@
|
|||||||
<!-- Shop Management -->
|
<!-- Shop Management -->
|
||||||
<h2 class="h6 fw-semibold mb-2 text-muted text-uppercase" style="letter-spacing:.05em; font-size:.7rem;">Shop Management</h2>
|
<h2 class="h6 fw-semibold mb-2 text-muted text-uppercase" style="letter-spacing:.05em; font-size:.7rem;">Shop Management</h2>
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="card border-0 shadow-sm h-100">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-flex align-items-start gap-3">
|
|
||||||
<div class="rounded-3 bg-info bg-opacity-10 p-2 flex-shrink-0">
|
|
||||||
<i class="bi bi-person-badge text-info fs-4"></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h5 class="card-title mb-1">Shop Workers</h5>
|
|
||||||
<p class="card-text text-muted small mb-2">Add floor staff, assign roles like Coater or Sandblaster, and link workers to jobs and maintenance tasks.</p>
|
|
||||||
<a asp-controller="Help" asp-action="ShopWorkers" class="btn btn-sm btn-outline-info">Read more <i class="bi bi-arrow-right ms-1"></i></a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="card border-0 shadow-sm h-100">
|
<div class="card border-0 shadow-sm h-100">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|||||||
@@ -1,226 +0,0 @@
|
|||||||
@{
|
|
||||||
ViewData["Title"] = "Shop Workers";
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="d-flex align-items-center gap-2 mb-3">
|
|
||||||
<a asp-controller="Help" asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
|
|
||||||
<nav aria-label="breadcrumb">
|
|
||||||
<ol class="breadcrumb mb-0">
|
|
||||||
<li class="breadcrumb-item"><a asp-controller="Help" asp-action="Index">Help</a></li>
|
|
||||||
<li class="breadcrumb-item active">Shop Workers</li>
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-4">
|
|
||||||
<div class="col-lg-9">
|
|
||||||
|
|
||||||
<section id="overview" class="mb-5">
|
|
||||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
|
||||||
<i class="bi bi-info-circle text-primary me-2"></i>Overview
|
|
||||||
</h2>
|
|
||||||
<p>
|
|
||||||
Shop Workers are the people who do the hands-on work in your facility — sandblasters, coaters,
|
|
||||||
maskers, oven operators, and supervisors. Adding your workers to the system lets you assign them
|
|
||||||
to jobs and maintenance tasks, giving you a clear picture of who is working on what at any time.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Shop Workers are separate from system user accounts. A worker does not need to log into the
|
|
||||||
system — they are simply a record that can be assigned to work. If a worker also needs to log
|
|
||||||
in and update job statuses themselves, an Administrator can create a linked user account for
|
|
||||||
them with the <em>Shop Floor</em> role.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Find Shop Workers under <strong>Operations › Shop Workers</strong> in the sidebar.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="adding-a-worker" class="mb-5">
|
|
||||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
|
||||||
<i class="bi bi-person-plus text-primary me-2"></i>Adding a Worker
|
|
||||||
</h2>
|
|
||||||
<p>To add a new shop worker:</p>
|
|
||||||
<ol class="mb-3">
|
|
||||||
<li class="mb-2">Go to <strong>Operations › Shop Workers</strong> and click <strong>New Worker</strong>.</li>
|
|
||||||
<li class="mb-2">
|
|
||||||
Fill in the worker's details:
|
|
||||||
<ul class="mt-1">
|
|
||||||
<li><strong>Name</strong> — the worker's full name as it should appear on job assignments.</li>
|
|
||||||
<li><strong>Role</strong> — select the role that best describes their primary function (see below).</li>
|
|
||||||
<li><strong>Phone</strong> — optional, useful for supervisors to have on file.</li>
|
|
||||||
<li><strong>Email</strong> — optional, used if the worker also has a system login.</li>
|
|
||||||
<li><strong>Notes</strong> — any relevant information, such as certifications, shift preferences, or specialties.</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li class="mb-2">Ensure <strong>Active</strong> is checked (it is on by default).</li>
|
|
||||||
<li class="mb-2">Click <strong>Save Worker</strong>.</li>
|
|
||||||
</ol>
|
|
||||||
<p>
|
|
||||||
Once saved, the worker will appear in the assignment dropdowns on the Job Create and Edit forms.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="worker-roles" class="mb-5">
|
|
||||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
|
||||||
<i class="bi bi-tags text-primary me-2"></i>Worker Roles
|
|
||||||
</h2>
|
|
||||||
<p>
|
|
||||||
Each worker is assigned one of the following roles. The role is a label — it helps you pick the
|
|
||||||
right person for a job but does not restrict what a worker can be assigned to.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-bordered align-middle">
|
|
||||||
<thead class="table-light">
|
|
||||||
<tr>
|
|
||||||
<th style="width:25%">Role</th>
|
|
||||||
<th>Description</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><span class="badge bg-secondary">General Labor</span></td>
|
|
||||||
<td>
|
|
||||||
Versatile workers who assist across multiple areas of the shop — loading and unloading,
|
|
||||||
racking parts, clean-up, and general support tasks. Not specialized in a single process.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><span class="badge bg-warning text-dark">Sandblaster</span></td>
|
|
||||||
<td>
|
|
||||||
Operates the sandblasting or media-blasting equipment to prepare metal surfaces for
|
|
||||||
coating. Responsible for achieving the correct surface profile and ensuring all rust,
|
|
||||||
paint, and contamination is removed.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><span class="badge bg-primary">Coater</span></td>
|
|
||||||
<td>
|
|
||||||
Applies powder coating using an electrostatic spray gun. Responsible for even coverage,
|
|
||||||
correct mil thickness, and minimizing overspray and waste. Often the most skilled
|
|
||||||
technical role on the floor.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><span class="badge bg-info text-dark">Masker</span></td>
|
|
||||||
<td>
|
|
||||||
Applies masking tape, plugs, and caps to protect threads, bearing surfaces, and areas
|
|
||||||
that must not be coated. Attention to detail is critical — missed masking means rework.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><span class="badge bg-success">Quality Control</span></td>
|
|
||||||
<td>
|
|
||||||
Inspects finished parts for adhesion, color consistency, coverage, and surface defects
|
|
||||||
before the job is marked as complete. May also handle pre-coat inspection after
|
|
||||||
sandblasting.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><span class="badge bg-danger">Oven Operator</span></td>
|
|
||||||
<td>
|
|
||||||
Loads parts into the curing oven, sets correct temperatures and cure times for the
|
|
||||||
powder being used, monitors the cure cycle, and unloads parts safely after cooling.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><span class="badge bg-dark">Supervisor</span></td>
|
|
||||||
<td>
|
|
||||||
Oversees day-to-day shop floor operations, assigns tasks to other workers, ensures
|
|
||||||
jobs are progressing on schedule, and handles escalations. May also handle customer
|
|
||||||
communication for production updates.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><span class="badge bg-secondary">Maintenance</span></td>
|
|
||||||
<td>
|
|
||||||
Responsible for keeping equipment running — performing scheduled preventive maintenance,
|
|
||||||
troubleshooting breakdowns, and coordinating with external service technicians when
|
|
||||||
needed.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="assigning-to-jobs" class="mb-5">
|
|
||||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
|
||||||
<i class="bi bi-briefcase text-primary me-2"></i>Assigning Workers to Jobs
|
|
||||||
</h2>
|
|
||||||
<p>
|
|
||||||
Each job can have one worker assigned to it as the primary responsible person. This is the
|
|
||||||
worker who owns the job from start to finish — typically a coater or supervisor.
|
|
||||||
</p>
|
|
||||||
<p>To assign a worker when creating or editing a job:</p>
|
|
||||||
<ol class="mb-3">
|
|
||||||
<li class="mb-1">Open the job's Create or Edit form.</li>
|
|
||||||
<li class="mb-1">Scroll down to the <strong>Assignment</strong> section.</li>
|
|
||||||
<li class="mb-1">Select a worker from the <strong>Assigned Worker</strong> dropdown. Only active workers are listed.</li>
|
|
||||||
<li class="mb-1">Save the job.</li>
|
|
||||||
</ol>
|
|
||||||
<p>
|
|
||||||
The assigned worker's name appears on the job list view, on the job detail page, and in any
|
|
||||||
reports filtered by worker.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Workers can also be assigned to <strong>maintenance tasks</strong> on equipment. See the
|
|
||||||
<a asp-controller="Help" asp-action="Equipment" class="text-decoration-none">Equipment & Maintenance</a>
|
|
||||||
help page for details.
|
|
||||||
</p>
|
|
||||||
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
|
|
||||||
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
|
|
||||||
<div>
|
|
||||||
If a worker you want to assign does not appear in the dropdown, check that their record is
|
|
||||||
marked as <strong>Active</strong>. Inactive workers are hidden from assignment lists.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="deactivating-a-worker" class="mb-5">
|
|
||||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
|
||||||
<i class="bi bi-person-dash text-primary me-2"></i>Deactivating a Worker
|
|
||||||
</h2>
|
|
||||||
<p>
|
|
||||||
When a worker leaves the shop or is no longer available for assignment, deactivate their record
|
|
||||||
rather than deleting it. Deactivating preserves the history of all jobs they were assigned to,
|
|
||||||
while removing them from the active assignment dropdowns so they cannot be accidentally selected
|
|
||||||
for new work.
|
|
||||||
</p>
|
|
||||||
<p>To deactivate a worker:</p>
|
|
||||||
<ol class="mb-3">
|
|
||||||
<li class="mb-1">Open the worker's Details or Edit page.</li>
|
|
||||||
<li class="mb-1">Uncheck the <strong>Active</strong> checkbox.</li>
|
|
||||||
<li class="mb-1">Click <strong>Save</strong>.</li>
|
|
||||||
</ol>
|
|
||||||
<p>
|
|
||||||
Alternatively, use the <strong>Delete</strong> button on the Details page to perform a soft
|
|
||||||
delete, which has the same effect.
|
|
||||||
</p>
|
|
||||||
<div class="alert alert-permanent alert-secondary d-flex gap-2 mb-0" role="alert">
|
|
||||||
<i class="bi bi-info-circle flex-shrink-0 mt-1"></i>
|
|
||||||
<div>
|
|
||||||
If a worker currently has open jobs assigned to them, reassign those jobs first before
|
|
||||||
deactivating the worker — so the jobs remain clearly owned and nothing falls through the cracks.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-lg-3 d-none d-lg-block">
|
|
||||||
@{ await Html.RenderPartialAsync("_HelpNav"); }
|
|
||||||
<div class="card border-0 shadow-sm sticky-top" style="top:80px">
|
|
||||||
<div class="card-header bg-transparent fw-semibold small text-muted text-uppercase" style="letter-spacing:.05em; font-size:.7rem;">On this page</div>
|
|
||||||
<div class="card-body p-0">
|
|
||||||
<nav class="nav flex-column">
|
|
||||||
<a class="nav-link py-1 px-3 small text-body" href="#overview">Overview</a>
|
|
||||||
<a class="nav-link py-1 px-3 small text-body" href="#adding-a-worker">Adding a Worker</a>
|
|
||||||
<a class="nav-link py-1 px-3 small text-body" href="#worker-roles">Worker Roles</a>
|
|
||||||
<a class="nav-link py-1 px-3 small text-body" href="#assigning-to-jobs">Assigning to Jobs</a>
|
|
||||||
<a class="nav-link py-1 px-3 small text-body" href="#deactivating-a-worker">Deactivating a Worker</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -65,11 +65,7 @@
|
|||||||
<div class="px-3 pt-2 pb-1">
|
<div class="px-3 pt-2 pb-1">
|
||||||
<span class="text-muted text-uppercase" style="font-size:.65rem; letter-spacing:.07em; font-weight:600;">Shop Management</span>
|
<span class="text-muted text-uppercase" style="font-size:.65rem; letter-spacing:.07em; font-weight:600;">Shop Management</span>
|
||||||
</div>
|
</div>
|
||||||
<a class="nav-link py-2 px-3 d-flex align-items-center gap-2 @(currentAction == "ShopWorkers" ? "active fw-semibold text-primary" : "text-body")"
|
<a class="nav-link py-2 px-3 d-flex align-items-center gap-2 @(currentAction == "Equipment" ? "active fw-semibold text-primary" : "text-body")"
|
||||||
asp-controller="Help" asp-action="ShopWorkers">
|
|
||||||
<i class="bi bi-person-badge"></i> Shop Workers
|
|
||||||
</a>
|
|
||||||
<a class="nav-link py-2 px-3 d-flex align-items-center gap-2 @(currentAction == "Equipment" ? "active fw-semibold text-primary" : "text-body")"
|
|
||||||
asp-controller="Help" asp-action="Equipment">
|
asp-controller="Help" asp-action="Equipment">
|
||||||
<i class="bi bi-tools"></i> Equipment & Maintenance
|
<i class="bi bi-tools"></i> Equipment & Maintenance
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -29,6 +29,61 @@
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="card border-0 shadow-sm">
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="mobile-card-view">
|
||||||
|
<div class="mobile-card-list">
|
||||||
|
@foreach (var n in items)
|
||||||
|
{
|
||||||
|
bool mIsRead = (bool)n.IsRead;
|
||||||
|
string mTitle = (string)n.Title;
|
||||||
|
string mMessage = (string)n.Message;
|
||||||
|
string? mLink = (string?)n.Link;
|
||||||
|
string mType = (string)n.NotificationType;
|
||||||
|
DateTime mCreatedAt = ((DateTime)n.CreatedAt).Tz(ViewBag.CompanyTimeZone as string);
|
||||||
|
<div class="mobile-data-card notif-history-row @(!mIsRead ? "notif-unread" : "")"
|
||||||
|
data-id="@n.Id"
|
||||||
|
data-title="@mTitle"
|
||||||
|
data-message="@mMessage"
|
||||||
|
data-link="@(mLink ?? "")"
|
||||||
|
data-type="@mType"
|
||||||
|
data-is-read="@(mIsRead ? "1" : "0")"
|
||||||
|
data-created-at="@mCreatedAt.ToString("MMM d, yyyy h:mm tt")">
|
||||||
|
<div class="mobile-card-header" style="@(!mIsRead ? "background:rgba(99,102,241,0.08);" : "")">
|
||||||
|
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);">
|
||||||
|
<i class="bi bi-bell"></i>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-title">
|
||||||
|
<h6 class="@(!mIsRead ? "fw-semibold" : "text-muted")">
|
||||||
|
@if (!mIsRead)
|
||||||
|
{
|
||||||
|
<span style="display:inline-block;width:8px;height:8px;background:#6366f1;border-radius:50%;margin-right:6px;"></span>
|
||||||
|
}
|
||||||
|
@mTitle
|
||||||
|
</h6>
|
||||||
|
<small>@mCreatedAt.ToString("MMM d, yyyy h:mm tt")</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-body">
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Type</span>
|
||||||
|
<span class="mobile-card-value"><span class="badge bg-secondary bg-opacity-25 text-body small">@mType</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-row" style="align-items:flex-start;">
|
||||||
|
<span class="mobile-card-label">Message</span>
|
||||||
|
<span class="mobile-card-value" style="white-space:normal;text-align:right;">@mMessage</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrEmpty(mLink))
|
||||||
|
{
|
||||||
|
<div class="mobile-card-footer">
|
||||||
|
<a href="@mLink" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
|
||||||
|
<i class="bi bi-arrow-right me-1"></i>Open
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover align-middle mb-0">
|
<table class="table table-hover align-middle mb-0">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
|
|||||||
@@ -126,6 +126,77 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
<div class="mobile-card-view">
|
||||||
|
<div class="mobile-card-list">
|
||||||
|
@{ lastMfr = null; }
|
||||||
|
@foreach (var item in needOrder)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(selectedMfr) && item.Manufacturer != lastMfr)
|
||||||
|
{
|
||||||
|
lastMfr = item.Manufacturer;
|
||||||
|
<div class="text-uppercase fw-semibold text-muted small px-2 py-1 border-bottom mt-2">
|
||||||
|
@(string.IsNullOrWhiteSpace(item.Manufacturer) ? "No Manufacturer" : item.Manufacturer)
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="mobile-data-card">
|
||||||
|
<div class="mobile-card-header">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(item.ColorCode))
|
||||||
|
{
|
||||||
|
<div class="mobile-card-icon" style="background: @(item.ColorCode.StartsWith("#") ? item.ColorCode : "#" + item.ColorCode); border: 1px solid var(--bs-border-color);"></div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #64748b 0%, #475569 100%);">
|
||||||
|
<i class="bi bi-palette"></i>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="mobile-card-title">
|
||||||
|
<h6>@(item.ColorName ?? item.Name)</h6>
|
||||||
|
<small>@(item.Manufacturer ?? "No Manufacturer")</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-body">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(item.ManufacturerPartNumber))
|
||||||
|
{
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Part #</span>
|
||||||
|
<span class="mobile-card-value text-muted">@item.ManufacturerPartNumber</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrWhiteSpace(item.Finish))
|
||||||
|
{
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Finish</span>
|
||||||
|
<span class="mobile-card-value">@item.Finish</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">In Stock</span>
|
||||||
|
<span class="mobile-card-value">
|
||||||
|
@if (item.QuantityOnHand > 0)
|
||||||
|
{
|
||||||
|
<span class="badge bg-success bg-opacity-10 text-success">@item.QuantityOnHand.ToString("N2") @item.UnitOfMeasure</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="text-muted">None</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-footer">
|
||||||
|
<button class="btn btn-sm btn-outline-success btn-toggle-panel"
|
||||||
|
data-item-id="@item.Id" data-has-panel="true">
|
||||||
|
<i class="bi bi-check-lg me-1"></i>Got It
|
||||||
|
</button>
|
||||||
|
<a asp-action="Details" asp-route-id="@item.Id" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover mb-0" id="needTable">
|
<table class="table table-hover mb-0" id="needTable">
|
||||||
<thead class="table-group-divider">
|
<thead class="table-group-divider">
|
||||||
@@ -220,6 +291,68 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
<div class="mobile-card-view">
|
||||||
|
<div class="mobile-card-list">
|
||||||
|
@{ lastMfr = null; }
|
||||||
|
@foreach (var item in onHand)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(selectedMfr) && item.Manufacturer != lastMfr)
|
||||||
|
{
|
||||||
|
lastMfr = item.Manufacturer;
|
||||||
|
<div class="text-uppercase fw-semibold text-muted small px-2 py-1 border-bottom mt-2">
|
||||||
|
@(string.IsNullOrWhiteSpace(item.Manufacturer) ? "No Manufacturer" : item.Manufacturer)
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="mobile-data-card">
|
||||||
|
<div class="mobile-card-header">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(item.ColorCode))
|
||||||
|
{
|
||||||
|
<div class="mobile-card-icon" style="background: @(item.ColorCode.StartsWith("#") ? item.ColorCode : "#" + item.ColorCode); border: 1px solid var(--bs-border-color);"></div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #059669 0%, #047857 100%);">
|
||||||
|
<i class="bi bi-palette"></i>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="mobile-card-title">
|
||||||
|
<h6>@(item.ColorName ?? item.Name)</h6>
|
||||||
|
<small>@(item.Manufacturer ?? "No Manufacturer")</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-body">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(item.ManufacturerPartNumber))
|
||||||
|
{
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Part #</span>
|
||||||
|
<span class="mobile-card-value text-muted">@item.ManufacturerPartNumber</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrWhiteSpace(item.Finish))
|
||||||
|
{
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Finish</span>
|
||||||
|
<span class="mobile-card-value">@item.Finish</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Status</span>
|
||||||
|
<span class="mobile-card-value"><span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>On Wall</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-footer">
|
||||||
|
<button class="btn btn-sm btn-outline-danger btn-toggle-panel"
|
||||||
|
data-item-id="@item.Id" data-has-panel="false">
|
||||||
|
<i class="bi bi-x-lg me-1"></i>Remove
|
||||||
|
</button>
|
||||||
|
<a asp-action="Details" asp-route-id="@item.Id" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover mb-0">
|
<table class="table table-hover mb-0">
|
||||||
<thead class="table-group-divider">
|
<thead class="table-group-divider">
|
||||||
|
|||||||
@@ -168,6 +168,23 @@
|
|||||||
}
|
}
|
||||||
.reason-pill.selected { border-color: var(--purple); background: #f3effe; color: var(--purple); font-weight: 600; }
|
.reason-pill.selected { border-color: var(--purple); background: #f3effe; color: var(--purple); font-weight: 600; }
|
||||||
|
|
||||||
|
/* ── Input mode toggle ───────────────────────── */
|
||||||
|
.mode-toggle { display: flex; border: 1.5px solid var(--border); border-radius: 8px; overflow: hidden; margin-bottom: 18px; }
|
||||||
|
.mode-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 8px;
|
||||||
|
background: #fff;
|
||||||
|
border: none;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
transition: background .15s, color .15s;
|
||||||
|
}
|
||||||
|
.mode-btn.active { background: var(--purple); color: #fff; }
|
||||||
|
.mode-btn:first-child { border-right: 1.5px solid var(--border); }
|
||||||
|
|
||||||
/* ── Submit / Cancel ─────────────────────────── */
|
/* ── Submit / Cancel ─────────────────────────── */
|
||||||
.btn-submit {
|
.btn-submit {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -309,12 +326,28 @@
|
|||||||
|
|
||||||
<div class="form-card">
|
<div class="form-card">
|
||||||
<h2>2. Enter Quantity</h2>
|
<h2>2. Enter Quantity</h2>
|
||||||
<div class="field">
|
|
||||||
|
<div class="mode-toggle">
|
||||||
|
<button type="button" class="mode-btn active" id="modeUsed" onclick="setMode('used')">Amount Used</button>
|
||||||
|
<button type="button" class="mode-btn" id="modeRemaining" onclick="setMode('remaining')">Remaining Weight</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- amount-used mode -->
|
||||||
|
<div id="usedField" class="field">
|
||||||
<label for="quantityInput">Amount Used (@item.UnitOfMeasure) <span class="req">*</span></label>
|
<label for="quantityInput">Amount Used (@item.UnitOfMeasure) <span class="req">*</span></label>
|
||||||
<input type="number" id="quantityInput" name="quantity"
|
<input type="number" id="quantityInput" name="quantity"
|
||||||
min="0" step="any" required placeholder="0" inputmode="decimal" />
|
min="0" step="any" placeholder="0" inputmode="decimal"
|
||||||
|
oninvalid="this.setCustomValidity('')" />
|
||||||
<div class="hint" id="balanceHint"></div>
|
<div class="hint" id="balanceHint"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- remaining-weight mode -->
|
||||||
|
<div id="remainingField" class="field" style="display:none">
|
||||||
|
<label for="remainingInput">Weight Remaining (@item.UnitOfMeasure) <span class="req">*</span></label>
|
||||||
|
<input type="number" id="remainingInput" min="0" step="any"
|
||||||
|
placeholder="0" inputmode="decimal" />
|
||||||
|
<div class="hint" id="remainingHint"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-card">
|
<div class="form-card">
|
||||||
@@ -346,6 +379,21 @@
|
|||||||
<script>
|
<script>
|
||||||
var currentQty = @item.QuantityOnHand;
|
var currentQty = @item.QuantityOnHand;
|
||||||
var uom = '@item.UnitOfMeasure';
|
var uom = '@item.UnitOfMeasure';
|
||||||
|
var inputMode = 'used'; // 'used' | 'remaining'
|
||||||
|
|
||||||
|
// ── Input mode toggle ────────────────────────────
|
||||||
|
function setMode(mode) {
|
||||||
|
inputMode = mode;
|
||||||
|
document.getElementById('modeUsed').classList.toggle('active', mode === 'used');
|
||||||
|
document.getElementById('modeRemaining').classList.toggle('active', mode === 'remaining');
|
||||||
|
document.getElementById('usedField').style.display = mode === 'used' ? '' : 'none';
|
||||||
|
document.getElementById('remainingField').style.display = mode === 'remaining' ? '' : 'none';
|
||||||
|
document.getElementById('balanceHint').textContent = '';
|
||||||
|
document.getElementById('remainingHint').textContent = '';
|
||||||
|
// clear both inputs when switching
|
||||||
|
document.getElementById('quantityInput').value = '';
|
||||||
|
document.getElementById('remainingInput').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
// ── Job selection ────────────────────────────────
|
// ── Job selection ────────────────────────────────
|
||||||
function showTab(tab) {
|
function showTab(tab) {
|
||||||
@@ -384,7 +432,7 @@
|
|||||||
document.getElementById('transactionTypeInput').value = el.dataset.val;
|
document.getElementById('transactionTypeInput').value = el.dataset.val;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Balance hint ─────────────────────────────────
|
// ── Balance hint (amount-used mode) ─────────────
|
||||||
document.getElementById('quantityInput').addEventListener('input', function() {
|
document.getElementById('quantityInput').addEventListener('input', function() {
|
||||||
var qty = parseFloat(this.value) || 0;
|
var qty = parseFloat(this.value) || 0;
|
||||||
if (!this.value) { document.getElementById('balanceHint').textContent = ''; return; }
|
if (!this.value) { document.getElementById('balanceHint').textContent = ''; return; }
|
||||||
@@ -394,6 +442,24 @@
|
|||||||
'New balance: <strong style="color:' + col + '">' + newBal.toFixed(2) + ' ' + uom + '</strong>';
|
'New balance: <strong style="color:' + col + '">' + newBal.toFixed(2) + ' ' + uom + '</strong>';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Remaining-weight hint ────────────────────────
|
||||||
|
document.getElementById('remainingInput').addEventListener('input', function() {
|
||||||
|
var hint = document.getElementById('remainingHint');
|
||||||
|
if (!this.value) { hint.textContent = ''; return; }
|
||||||
|
var remaining = parseFloat(this.value);
|
||||||
|
if (isNaN(remaining) || remaining < 0) { hint.innerHTML = '<span style="color:var(--danger)">Enter a valid weight.</span>'; return; }
|
||||||
|
if (remaining > currentQty) {
|
||||||
|
hint.innerHTML = '<span style="color:var(--danger)">Remaining cannot exceed current stock (' + currentQty.toFixed(2) + ' ' + uom + ').</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var used = currentQty - remaining;
|
||||||
|
if (used <= 0) {
|
||||||
|
hint.innerHTML = '<span style="color:var(--danger)">No usage to log — remaining equals current stock.</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hint.innerHTML = 'Will log <strong>' + used.toFixed(2) + ' ' + uom + '</strong> as used — new balance: <strong style="color:' + (remaining === 0 ? '#343a40' : 'var(--success)') + '">' + remaining.toFixed(2) + ' ' + uom + '</strong>';
|
||||||
|
});
|
||||||
|
|
||||||
// ── Preselect job if coming from success page ────
|
// ── Preselect job if coming from success page ────
|
||||||
@if (preselectedJobId.HasValue)
|
@if (preselectedJobId.HasValue)
|
||||||
{
|
{
|
||||||
@@ -406,8 +472,37 @@
|
|||||||
</text>
|
</text>
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Submit spinner ───────────────────────────────
|
// ── Submit: resolve quantity from whichever mode is active ──
|
||||||
document.getElementById('usageForm').addEventListener('submit', function() {
|
document.getElementById('usageForm').addEventListener('submit', function(e) {
|
||||||
|
if (inputMode === 'remaining') {
|
||||||
|
var remaining = parseFloat(document.getElementById('remainingInput').value);
|
||||||
|
if (isNaN(remaining) || remaining < 0 || remaining > currentQty) {
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById('remainingHint').innerHTML =
|
||||||
|
'<span style="color:var(--danger)">Please enter a valid remaining weight.</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var used = currentQty - remaining;
|
||||||
|
if (used <= 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById('remainingHint').innerHTML =
|
||||||
|
'<span style="color:var(--danger)">No usage to log — remaining equals current stock.</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('quantityInput').value = used.toFixed(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate amount-used mode
|
||||||
|
if (inputMode === 'used') {
|
||||||
|
var qty = parseFloat(document.getElementById('quantityInput').value);
|
||||||
|
if (isNaN(qty) || qty <= 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById('balanceHint').innerHTML =
|
||||||
|
'<span style="color:var(--danger)">Please enter a quantity greater than zero.</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var btn = document.getElementById('submitBtn');
|
var btn = document.getElementById('submitBtn');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Saving…';
|
btn.textContent = 'Saving…';
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
<a tabindex="0" class="help-icon" role="button"
|
<a tabindex="0" class="help-icon" role="button"
|
||||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||||
data-bs-title="Invoice Details"
|
data-bs-title="Invoice Details"
|
||||||
data-bs-content="Invoice Date is the date of issue and the reference for payment terms. Due Date drives overdue status and A/R aging. Payment Terms prints on the invoice — changing it here only affects this invoice. Draft, Sent, and Overdue invoices can be edited; Paid and Partially Paid invoices are locked.">
|
data-bs-content="Invoice Date is the date of issue and the reference for payment terms. Due Date drives overdue status and A/R aging. Payment Terms prints on the invoice — changing it here only affects this invoice. Draft, Sent, and Overdue invoices can be edited; Paid and Partially Paid invoices are locked.">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
<a tabindex="0" class="help-icon" role="button"
|
<a tabindex="0" class="help-icon" role="button"
|
||||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||||
data-bs-title="Line Items"
|
data-bs-title="Line Items"
|
||||||
data-bs-content="Each row is a billable line on the invoice. Qty × Unit Price = Total per line; you can also override Total directly. Color is optional and appears under the description when printed. Add manual lines for charges not in the original job (e.g., rush fee, pickup charge).">
|
data-bs-content="Each row is a billable line on the invoice. Qty × Unit Price = Total per line; you can also override Total directly. Color is optional and appears under the description when printed. Add manual lines for charges not in the original job (e.g., rush fee, pickup charge).">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -163,7 +163,7 @@
|
|||||||
<a tabindex="0" class="help-icon" role="button"
|
<a tabindex="0" class="help-icon" role="button"
|
||||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||||
data-bs-title="Notes"
|
data-bs-title="Notes"
|
||||||
data-bs-content="Customer Notes appear on the printed and emailed invoice — use these for payment instructions, thank-you messages, or job-specific reminders. Internal Notes are only visible to staff in the app and are never sent to the customer.">
|
data-bs-content="Customer Notes appear on the printed and emailed invoice — use these for payment instructions, thank-you messages, or job-specific reminders. Internal Notes are only visible to staff in the app and are never sent to the customer.">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -144,7 +144,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>@inv.InvoiceDate.ToString("MM/dd/yyyy")</td>
|
<td>@inv.InvoiceDate.ToString("MM/dd/yyyy")</td>
|
||||||
<td class="@(inv.IsOverdue ? "fw-bold text-danger" : "")">
|
<td class="@(inv.IsOverdue ? "fw-bold text-danger" : "")">
|
||||||
@(inv.DueDate.HasValue ? inv.DueDate.Value.ToString("MM/dd/yyyy") : "—")
|
@(inv.DueDate.HasValue ? inv.DueDate.Value.ToString("MM/dd/yyyy") : "—")
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end">@inv.Total.ToString("C")</td>
|
<td class="text-end">@inv.Total.ToString("C")</td>
|
||||||
<td class="text-end @(inv.BalanceDue > 0 ? "fw-semibold" : "text-muted")">
|
<td class="text-end @(inv.BalanceDue > 0 ? "fw-semibold" : "text-muted")">
|
||||||
@@ -167,6 +167,77 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mobile-card-view">
|
||||||
|
<div class="mobile-card-list">
|
||||||
|
@foreach (var inv in Model.Items)
|
||||||
|
{
|
||||||
|
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", "Invoices", new { id = inv.Id })'">
|
||||||
|
<div class="mobile-card-header" style="@(inv.IsOverdue ? "background:#fee2e2;" : "")">
|
||||||
|
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);">
|
||||||
|
<i class="bi bi-receipt"></i>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-title">
|
||||||
|
<h6>@inv.InvoiceNumber</h6>
|
||||||
|
<small>@inv.CustomerName</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-body">
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Status</span>
|
||||||
|
<span class="mobile-card-value">
|
||||||
|
@await Html.PartialAsync("_StatusChip", (Kind: StatusChipHelper.InvoiceStatus(inv.Status), Text: InvoicesController.GetStatusDisplay(inv.Status)))
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@if (inv.JobId.HasValue)
|
||||||
|
{
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Job</span>
|
||||||
|
<span class="mobile-card-value">
|
||||||
|
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@inv.JobId"
|
||||||
|
class="text-decoration-none" onclick="event.stopPropagation()">
|
||||||
|
@inv.JobNumber
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Date</span>
|
||||||
|
<span class="mobile-card-value">@inv.InvoiceDate.ToString("MM/dd/yy")</span>
|
||||||
|
</div>
|
||||||
|
@if (inv.DueDate.HasValue)
|
||||||
|
{
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Due</span>
|
||||||
|
<span class="mobile-card-value @(inv.IsOverdue ? "fw-bold text-danger" : "")">
|
||||||
|
@inv.DueDate.Value.ToString("MM/dd/yy")
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Total</span>
|
||||||
|
<span class="mobile-card-value">@inv.Total.ToString("C")</span>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Balance Due</span>
|
||||||
|
<span class="mobile-card-value @(inv.BalanceDue > 0 ? "fw-semibold" : "text-muted")">
|
||||||
|
@inv.BalanceDue.ToString("C")
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-footer">
|
||||||
|
<a asp-action="Details" asp-route-id="@inv.Id"
|
||||||
|
class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
|
||||||
|
<i class="bi bi-eye me-1"></i>View
|
||||||
|
</a>
|
||||||
|
<a asp-action="DownloadPdf" asp-route-id="@inv.Id"
|
||||||
|
class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
|
||||||
|
<i class="bi bi-file-pdf me-1"></i>PDF
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="px-3">
|
<div class="px-3">
|
||||||
@await Html.PartialAsync("_Pagination", Model)
|
@await Html.PartialAsync("_Pagination", Model)
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@model PowderCoating.Application.DTOs.Job.CreateJobDto
|
@model PowderCoating.Application.DTOs.Job.CreateJobDto
|
||||||
@using PowderCoating.Core.Entities
|
@using PowderCoating.Core.Entities
|
||||||
|
|
||||||
@{
|
@{
|
||||||
@@ -313,96 +313,8 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Surface Area Calculator Modal -->
|
@await Html.PartialAsync("_SqFtCalculatorModal")
|
||||||
<div class="modal fade" id="sqFtCalculatorModal" tabindex="-1">
|
@await Html.PartialAsync("_ItemWizardModal")
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title"><i class="bi bi-calculator me-2"></i>Surface Area Calculator <small class="text-muted">(per item)</small></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">Shape</label>
|
|
||||||
<select id="calcShape" class="form-select" onchange="toggleShapeInputs()">
|
|
||||||
<option value="rectangle">Rectangle / Square</option>
|
|
||||||
<option value="cylinder">Cylinder (Tube)</option>
|
|
||||||
<option value="circle">Circle (Flat)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div id="rectangleInputs">
|
|
||||||
<div class="row g-2">
|
|
||||||
<div class="col-6"><label class="form-label">Length (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
|
||||||
<input type="number" id="rectLength" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
|
||||||
<div class="col-6"><label class="form-label">Width (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
|
||||||
<input type="number" id="rectWidth" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
|
||||||
</div>
|
|
||||||
<small class="text-muted">Formula: L × W ÷ @(ViewBag.UseMetric == true ? "10,000" : "144")</small>
|
|
||||||
</div>
|
|
||||||
<div id="cylinderInputs" style="display:none">
|
|
||||||
<div class="row g-2">
|
|
||||||
<div class="col-6"><label class="form-label">Diameter (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
|
||||||
<input type="number" id="cylDiameter" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
|
||||||
<div class="col-6"><label class="form-label">Height (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
|
||||||
<input type="number" id="cylHeight" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="circleInputs" style="display:none">
|
|
||||||
<label class="form-label">Diameter (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
|
||||||
<input type="number" id="circDiameter" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()">
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<div class="alert alert-info alert-permanent mb-0"><strong>Result:</strong> <span id="calcResult">0.00</span> @ViewBag.AreaUnit</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<button type="button" class="btn btn-primary" onclick="useSqFtResult()">
|
|
||||||
<i class="bi bi-check-circle me-1"></i>Use This Value
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Item Wizard Modal -->
|
|
||||||
<div class="modal fade" id="itemWizardModal" tabindex="-1" data-bs-backdrop="static">
|
|
||||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<div class="d-flex flex-column">
|
|
||||||
<h5 class="modal-title mb-0" id="wizardTitle">Add Item</h5>
|
|
||||||
<div class="text-muted small mb-1" id="wizardStepTitle">Choose Item Type</div>
|
|
||||||
<div class="d-flex align-items-center gap-2" id="wizardStepIndicator">
|
|
||||||
<span class="wizard-step-dot active" data-step="1" title="Item Type"></span>
|
|
||||||
<div class="wizard-step-line"></div>
|
|
||||||
<span class="wizard-step-dot" data-step="2" title="Item Details"></span>
|
|
||||||
<div class="wizard-step-line" id="step2Line"></div>
|
|
||||||
<span class="wizard-step-dot" data-step="3" title="Coating Layers" id="step3Dot"></span>
|
|
||||||
<div class="wizard-step-line" id="step3Line"></div>
|
|
||||||
<span class="wizard-step-dot" data-step="4" title="Prep Services" id="step4Dot"></span>
|
|
||||||
<span class="text-muted small ms-2" id="wizardStepLabel">Step 1 of 4</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn-close ms-auto" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body" id="wizardBody" style="min-height: 300px;"></div>
|
|
||||||
<div class="modal-footer justify-content-between">
|
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<button type="button" class="btn btn-outline-secondary d-none" id="btnWizardBack" onclick="wizardBack()">
|
|
||||||
<i class="bi bi-arrow-left me-1"></i>Back
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-primary" id="btnWizardNext" onclick="wizardNext()">
|
|
||||||
Next <i class="bi bi-arrow-right ms-1"></i>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-success d-none" id="btnWizardSave" onclick="wizardSave()">
|
|
||||||
<i class="bi bi-check-lg me-1"></i>Add Item
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Embedded data for JS -->
|
<!-- Embedded data for JS -->
|
||||||
@if (ViewBag.InventoryCoatings != null)
|
@if (ViewBag.InventoryCoatings != null)
|
||||||
@@ -489,41 +401,7 @@
|
|||||||
|
|
||||||
@section Styles {
|
@section Styles {
|
||||||
<link rel="stylesheet" href="~/lib/tom-select/css/tom-select.bootstrap5.min.css">
|
<link rel="stylesheet" href="~/lib/tom-select/css/tom-select.bootstrap5.min.css">
|
||||||
<style>
|
<link rel="stylesheet" href="~/css/item-wizard.css">
|
||||||
.wizard-step-dot {
|
|
||||||
width: 22px; height: 22px; border-radius: 50%;
|
|
||||||
background: #dee2e6; display: inline-block; cursor: default;
|
|
||||||
border: 2px solid #dee2e6; transition: all .2s; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.wizard-step-dot.active { background: #0d6efd; border-color: #0d6efd; }
|
|
||||||
.wizard-step-dot.done { background: #198754; border-color: #198754; }
|
|
||||||
.wizard-step-dot.skip { background: #adb5bd; border-color: #adb5bd; }
|
|
||||||
.wizard-step-line { flex: 1; height: 2px; background: #dee2e6; min-width: 30px; }
|
|
||||||
.item-type-card {
|
|
||||||
border: 2px solid #dee2e6; border-radius: .75rem; padding: 1.25rem 1rem;
|
|
||||||
cursor: pointer; transition: all .15s; text-align: center;
|
|
||||||
background: #fff; user-select: none;
|
|
||||||
}
|
|
||||||
.item-type-card:hover { border-color: #86b7fe; background: #f0f6ff; }
|
|
||||||
.item-type-card.selected { border-color: #0d6efd; background: #eef3ff; }
|
|
||||||
.item-type-card .item-type-icon { font-size: 2rem; margin-bottom: .5rem; }
|
|
||||||
[data-bs-theme="dark"] .item-type-card { background: var(--bs-tertiary-bg); border-color: var(--bs-border-color); color: var(--bs-body-color); }
|
|
||||||
[data-bs-theme="dark"] .item-type-card:hover { border-color: #86b7fe; background: var(--bs-secondary-bg); }
|
|
||||||
[data-bs-theme="dark"] .item-type-card.selected { border-color: #0d6efd; background: #1a2a4a; }
|
|
||||||
.catalog-list-item { cursor: pointer; border-bottom: 1px solid var(--bs-border-color); font-size: .9rem; transition: background .1s; }
|
|
||||||
.catalog-list-item:last-child { border-bottom: none; }
|
|
||||||
.catalog-list-item:hover { background: var(--bs-tertiary-bg); }
|
|
||||||
.catalog-list-item.selected { background: #eef3ff; color: #0d6efd; font-weight: 600; }
|
|
||||||
[data-bs-theme="dark"] .catalog-list-item.selected { background: #1a2a4a; color: #86b7fe; }
|
|
||||||
.quote-item-card {
|
|
||||||
border: 1px solid #dee2e6; border-radius: .5rem;
|
|
||||||
padding: .75rem 1rem; margin-bottom: .5rem; background: #fafafa;
|
|
||||||
}
|
|
||||||
.quote-item-card .item-badge { font-size: .7rem; }
|
|
||||||
.coat-row { border: 1px solid #dee2e6; border-radius: .5rem; padding: .75rem; margin-bottom: .5rem; }
|
|
||||||
[data-bs-theme="dark"] .quote-item-card { background: var(--bs-tertiary-bg); border-color: var(--bs-border-color); color: var(--bs-body-color); }
|
|
||||||
[data-bs-theme="dark"] .coat-row { background: var(--bs-tertiary-bg); border-color: var(--bs-border-color); }
|
|
||||||
</style>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@model PowderCoating.Application.DTOs.Job.JobDto
|
@model PowderCoating.Application.DTOs.Job.JobDto
|
||||||
|
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = $"Job {Model.JobNumber}";
|
ViewData["Title"] = $"Job {Model.JobNumber}";
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<span>Shop work has started � review the quote and apply any changes manually.</span>
|
<span>Shop work has started — review the quote and apply any changes manually.</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2 flex-wrap">
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
@@ -217,7 +217,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="scheduledDate-saving" class="d-none mt-1 small text-muted">
|
<div id="scheduledDate-saving" class="d-none mt-1 small text-muted">
|
||||||
<span class="spinner-border spinner-border-sm me-1"></span>Saving�
|
<span class="spinner-border spinner-border-sm me-1"></span>Saving…
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -263,7 +263,7 @@
|
|||||||
<i class="bi bi-x-circle me-1"></i><small>Clear date</small>
|
<i class="bi bi-x-circle me-1"></i><small>Clear date</small>
|
||||||
</button>
|
</button>
|
||||||
<div id="dueDate-saving" class="d-none mt-1 small text-muted">
|
<div id="dueDate-saving" class="d-none mt-1 small text-muted">
|
||||||
<span class="spinner-border spinner-border-sm me-1"></span>Saving�
|
<span class="spinner-border spinner-border-sm me-1"></span>Saving…
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -273,7 +273,7 @@
|
|||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<select id="workerAssignmentSelect" class="form-select form-select-sm"
|
<select id="workerAssignmentSelect" class="form-select form-select-sm"
|
||||||
onchange="updateWorkerAssignment(this)">
|
onchange="updateWorkerAssignment(this)">
|
||||||
<option value="">� Unassigned �</option>
|
<option value="">– Unassigned –</option>
|
||||||
@foreach (var w in (IEnumerable<SelectListItem>)ViewBag.Workers)
|
@foreach (var w in (IEnumerable<SelectListItem>)ViewBag.Workers)
|
||||||
{
|
{
|
||||||
if (w.Value == Model.AssignedUserId)
|
if (w.Value == Model.AssignedUserId)
|
||||||
@@ -287,7 +287,7 @@
|
|||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
<span id="workerSaveIndicator" class="text-muted small d-none">
|
<span id="workerSaveIndicator" class="text-muted small d-none">
|
||||||
<span class="spinner-border spinner-border-sm me-1"></span>Saving�
|
<span class="spinner-border spinner-border-sm me-1"></span>Saving…
|
||||||
</span>
|
</span>
|
||||||
<span id="workerSavedTick" class="text-success small d-none">
|
<span id="workerSavedTick" class="text-success small d-none">
|
||||||
<i class="bi bi-check-circle-fill"></i>
|
<i class="bi bi-check-circle-fill"></i>
|
||||||
@@ -321,7 +321,7 @@
|
|||||||
|
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|
||||||
@* ── Catalog Products ── *@
|
@* -- Catalog Products -- *@
|
||||||
@if (catalogItems.Any())
|
@if (catalogItems.Any())
|
||||||
{
|
{
|
||||||
<h6 class="text-primary mb-3"><i class="bi bi-bag-check me-2"></i>Catalog Products</h6>
|
<h6 class="text-primary mb-3"><i class="bi bi-bag-check me-2"></i>Catalog Products</h6>
|
||||||
@@ -351,10 +351,10 @@
|
|||||||
{
|
{
|
||||||
<br />
|
<br />
|
||||||
<small class="ms-3">
|
<small class="ms-3">
|
||||||
� <strong>@coat.CoatName</strong>
|
• <strong>@coat.CoatName</strong>
|
||||||
@if (!string.IsNullOrEmpty(coat.ColorName))
|
@if (!string.IsNullOrEmpty(coat.ColorName))
|
||||||
{
|
{
|
||||||
<text> � @coat.ColorName</text>
|
<text> – @coat.ColorName</text>
|
||||||
@if (!string.IsNullOrEmpty(coat.VendorName))
|
@if (!string.IsNullOrEmpty(coat.VendorName))
|
||||||
{
|
{
|
||||||
<text> (@coat.VendorName)</text>
|
<text> (@coat.VendorName)</text>
|
||||||
@@ -373,7 +373,7 @@
|
|||||||
<span class="badge bg-info ms-1" style="font-size:.7em;">@coat.PowderToOrder.Value.ToString("0.##") lbs</span>
|
<span class="badge bg-info ms-1" style="font-size:.7em;">@coat.PowderToOrder.Value.ToString("0.##") lbs</span>
|
||||||
@if (!coat.InventoryItemId.HasValue)
|
@if (!coat.InventoryItemId.HasValue)
|
||||||
{
|
{
|
||||||
<span class="badge bg-warning text-dark ms-1" style="font-size:.7em;" title="Custom powder � must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER POWDER</span>
|
<span class="badge bg-warning text-dark ms-1" style="font-size:.7em;" title="Custom powder — must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER POWDER</span>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@if (!string.IsNullOrEmpty(coat.Notes))
|
@if (!string.IsNullOrEmpty(coat.Notes))
|
||||||
@@ -390,7 +390,7 @@
|
|||||||
@foreach (var ps in item.PrepServices)
|
@foreach (var ps in item.PrepServices)
|
||||||
{
|
{
|
||||||
<br />
|
<br />
|
||||||
<small class="ms-3">� <strong>@(ps.PrepServiceName ?? $"Service #{ps.PrepServiceId}")</strong> <span class="text-muted">� @ps.EstimatedMinutes min</span></small>
|
<small class="ms-3">• <strong>@(ps.PrepServiceName ?? $"Service #{ps.PrepServiceId}")</strong> <span class="text-muted">– @ps.EstimatedMinutes min</span></small>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@if (!string.IsNullOrEmpty(item.Notes))
|
@if (!string.IsNullOrEmpty(item.Notes))
|
||||||
@@ -414,7 +414,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@* ── Custom Work ── *@
|
@* -- Custom Work -- *@
|
||||||
@if (customItems.Any())
|
@if (customItems.Any())
|
||||||
{
|
{
|
||||||
<h6 class="text-success mb-3"><i class="bi bi-calculator me-2"></i>Custom Work</h6>
|
<h6 class="text-success mb-3"><i class="bi bi-calculator me-2"></i>Custom Work</h6>
|
||||||
@@ -478,10 +478,10 @@
|
|||||||
{
|
{
|
||||||
<br />
|
<br />
|
||||||
<small class="ms-3">
|
<small class="ms-3">
|
||||||
� <strong>@coat.CoatName</strong>
|
• <strong>@coat.CoatName</strong>
|
||||||
@if (!string.IsNullOrEmpty(coat.ColorName))
|
@if (!string.IsNullOrEmpty(coat.ColorName))
|
||||||
{
|
{
|
||||||
<text> � @coat.ColorName</text>
|
<text> – @coat.ColorName</text>
|
||||||
@if (!string.IsNullOrEmpty(coat.VendorName))
|
@if (!string.IsNullOrEmpty(coat.VendorName))
|
||||||
{
|
{
|
||||||
<text> (@coat.VendorName)</text>
|
<text> (@coat.VendorName)</text>
|
||||||
@@ -500,7 +500,7 @@
|
|||||||
<span class="badge bg-info ms-1" style="font-size:.7em;">@coat.PowderToOrder.Value.ToString("0.##") lbs</span>
|
<span class="badge bg-info ms-1" style="font-size:.7em;">@coat.PowderToOrder.Value.ToString("0.##") lbs</span>
|
||||||
@if (!coat.InventoryItemId.HasValue)
|
@if (!coat.InventoryItemId.HasValue)
|
||||||
{
|
{
|
||||||
<span class="badge bg-warning text-dark ms-1" style="font-size:.7em;" title="Custom powder � must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER POWDER</span>
|
<span class="badge bg-warning text-dark ms-1" style="font-size:.7em;" title="Custom powder — must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER POWDER</span>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@if (!string.IsNullOrEmpty(coat.Notes))
|
@if (!string.IsNullOrEmpty(coat.Notes))
|
||||||
@@ -517,7 +517,7 @@
|
|||||||
@foreach (var ps in item.PrepServices)
|
@foreach (var ps in item.PrepServices)
|
||||||
{
|
{
|
||||||
<br />
|
<br />
|
||||||
<small class="ms-3">� <strong>@(ps.PrepServiceName ?? $"Service #{ps.PrepServiceId}")</strong> <span class="text-muted">� @ps.EstimatedMinutes min</span></small>
|
<small class="ms-3">• <strong>@(ps.PrepServiceName ?? $"Service #{ps.PrepServiceId}")</strong> <span class="text-muted">– @ps.EstimatedMinutes min</span></small>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@if (!string.IsNullOrEmpty(item.Notes))
|
@if (!string.IsNullOrEmpty(item.Notes))
|
||||||
@@ -532,7 +532,7 @@
|
|||||||
<text>@item.SurfaceAreaSqFt.ToString("F2") @ViewBag.AreaUnit</text>
|
<text>@item.SurfaceAreaSqFt.ToString("F2") @ViewBag.AreaUnit</text>
|
||||||
<br /><small class="text-muted">per item</small>
|
<br /><small class="text-muted">per item</small>
|
||||||
}
|
}
|
||||||
else { <span class="text-muted">�</span> }
|
else { <span class="text-muted">—</span> }
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
@if (item.EstimatedMinutes > 0)
|
@if (item.EstimatedMinutes > 0)
|
||||||
@@ -540,7 +540,7 @@
|
|||||||
<text>@item.EstimatedMinutes min</text>
|
<text>@item.EstimatedMinutes min</text>
|
||||||
<br /><small class="text-muted">per item</small>
|
<br /><small class="text-muted">per item</small>
|
||||||
}
|
}
|
||||||
else { <span class="text-muted">�</span> }
|
else { <span class="text-muted">—</span> }
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
@if (totalPowderNeeded > 0)
|
@if (totalPowderNeeded > 0)
|
||||||
@@ -548,7 +548,7 @@
|
|||||||
<strong class="text-success">@totalPowderNeeded.ToString("F2") lbs</strong>
|
<strong class="text-success">@totalPowderNeeded.ToString("F2") lbs</strong>
|
||||||
<br /><small class="text-muted">total batch</small>
|
<br /><small class="text-muted">total batch</small>
|
||||||
}
|
}
|
||||||
else { <span class="text-muted">�</span> }
|
else { <span class="text-muted">—</span> }
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end">@item.UnitPrice.ToString("C")</td>
|
<td class="text-end">@item.UnitPrice.ToString("C")</td>
|
||||||
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
|
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
|
||||||
@@ -565,7 +565,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@* ── Labor ── *@
|
@* -- Labor -- *@
|
||||||
@if (laborItems.Any())
|
@if (laborItems.Any())
|
||||||
{
|
{
|
||||||
<h6 class="text-warning mb-3"><i class="bi bi-person-gear me-2"></i>Labor</h6>
|
<h6 class="text-warning mb-3"><i class="bi bi-person-gear me-2"></i>Labor</h6>
|
||||||
@@ -599,7 +599,7 @@
|
|||||||
{
|
{
|
||||||
<text>@item.EstimatedMinutes min</text>
|
<text>@item.EstimatedMinutes min</text>
|
||||||
}
|
}
|
||||||
else { <span class="text-muted">�</span> }
|
else { <span class="text-muted">—</span> }
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end">@item.UnitPrice.ToString("C")</td>
|
<td class="text-end">@item.UnitPrice.ToString("C")</td>
|
||||||
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
|
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
|
||||||
@@ -616,7 +616,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@* ── Mobile cards ── *@
|
@* -- Mobile cards -- *@
|
||||||
<div class="d-lg-none mt-2">
|
<div class="d-lg-none mt-2">
|
||||||
@foreach (var item in Model.Items)
|
@foreach (var item in Model.Items)
|
||||||
{
|
{
|
||||||
@@ -653,7 +653,7 @@
|
|||||||
<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@(!string.IsNullOrEmpty(coat.ColorName) ? $" � {coat.ColorName}" : "")</small>
|
<small class="d-block">@coat.CoatName@if (!string.IsNullOrEmpty(coat.ColorName)) { <text> – @coat.ColorName</text> }</small>
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -704,7 +704,7 @@
|
|||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex align-items-center gap-3">
|
||||||
<span class="text-muted small">Total: <strong id="totalHoursDisplay">�</strong></span>
|
<span class="text-muted small">Total: <strong id="totalHoursDisplay">—</strong></span>
|
||||||
@{
|
@{
|
||||||
var estimatedMins = Model.Items?.Sum(i => i.EstimatedMinutes * i.Quantity) ?? 0;
|
var estimatedMins = Model.Items?.Sum(i => i.EstimatedMinutes * i.Quantity) ?? 0;
|
||||||
var estimatedHrs = estimatedMins / 60m;
|
var estimatedHrs = estimatedMins / 60m;
|
||||||
@@ -741,7 +741,7 @@
|
|||||||
<tfoot class="table-light fw-semibold">
|
<tfoot class="table-light fw-semibold">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="3">Total</td>
|
<td colspan="3">Total</td>
|
||||||
<td class="text-end" id="timeEntriesTotalHours">�</td>
|
<td class="text-end" id="timeEntriesTotalHours">—</td>
|
||||||
<td colspan="3"></td>
|
<td colspan="3"></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
@@ -1099,7 +1099,7 @@
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title" id="intakeModalLabel">
|
<h5 class="modal-title" id="intakeModalLabel">
|
||||||
<i class="bi bi-box-seam me-2 text-info"></i>Part Intake � Check In
|
<i class="bi bi-box-seam me-2 text-info"></i>Part Intake – Check In
|
||||||
</h5>
|
</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1117,7 +1117,7 @@
|
|||||||
value="@(Model.IntakePartCount.HasValue ? Model.IntakePartCount.Value.ToString() : "")"
|
value="@(Model.IntakePartCount.HasValue ? Model.IntakePartCount.Value.ToString() : "")"
|
||||||
placeholder="@intakeExpectedCount" />
|
placeholder="@intakeExpectedCount" />
|
||||||
<div id="intakeMismatchAlert" class="alert alert-warning alert-permanent mt-2 py-2 d-none">
|
<div id="intakeMismatchAlert" class="alert alert-warning alert-permanent mt-2 py-2 d-none">
|
||||||
<i class="bi bi-exclamation-triangle me-1"></i>Count doesn't match expected � note the discrepancy below.
|
<i class="bi bi-exclamation-triangle me-1"></i>Count doesn't match expected — note the discrepancy below.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -1310,7 +1310,7 @@
|
|||||||
<a asp-action="Intake" asp-route-id="@Model.Id"
|
<a asp-action="Intake" asp-route-id="@Model.Id"
|
||||||
class="btn @(Model.IntakeDate.HasValue ? "btn-outline-secondary" : "btn-outline-info")"
|
class="btn @(Model.IntakeDate.HasValue ? "btn-outline-secondary" : "btn-outline-info")"
|
||||||
title="@(Model.IntakeDate.HasValue ? "Update part intake record" : "Check in parts for this job")">
|
title="@(Model.IntakeDate.HasValue ? "Update part intake record" : "Check in parts for this job")">
|
||||||
<i class=�bi bi-box-seam me-2�></i>@(Model.IntakeDate.HasValue ? "Intake ?" : "Intake")
|
<i class="bi bi-box-seam me-2"></i>@Html.Raw(Model.IntakeDate.HasValue ? "Intake ✓" : "Intake")
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
@{
|
@{
|
||||||
@@ -1368,7 +1368,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pricing Summary (internal � d-print-none) -->
|
<!-- Pricing Summary (internal - d-print-none) -->
|
||||||
@{
|
@{
|
||||||
var jobPb = ViewBag.JobPricingBreakdown as PowderCoating.Application.DTOs.Quote.QuotePricingBreakdownDto;
|
var jobPb = ViewBag.JobPricingBreakdown as PowderCoating.Application.DTOs.Quote.QuotePricingBreakdownDto;
|
||||||
}
|
}
|
||||||
@@ -1400,7 +1400,7 @@
|
|||||||
@if (jobPb.OvenBatchCost > 0)
|
@if (jobPb.OvenBatchCost > 0)
|
||||||
{
|
{
|
||||||
<div class="d-flex justify-content-between mb-2">
|
<div class="d-flex justify-content-between mb-2">
|
||||||
<span><i class="bi bi-fire me-1"></i>Oven (@jobPb.OvenBatches batch@(jobPb.OvenBatches != 1 ? "es" : "")@(jobPb.OvenCycleMinutes > 0 ? $" � {jobPb.OvenCycleMinutes} min" : "")):</span>
|
<span><i class="bi bi-fire me-1"></i>Oven (@jobPb.OvenBatches batch@(jobPb.OvenBatches != 1 ? "es" : "")@(jobPb.OvenCycleMinutes > 0 ? $" × {jobPb.OvenCycleMinutes} min" : "")):</span>
|
||||||
<strong>@jobPb.OvenBatchCost.ToString("C")</strong>
|
<strong>@jobPb.OvenBatchCost.ToString("C")</strong>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -1518,7 +1518,7 @@
|
|||||||
}
|
}
|
||||||
else if (allCatalog)
|
else if (allCatalog)
|
||||||
{
|
{
|
||||||
<div class="text-muted small fst-italic">All items use fixed catalog pricing � no per-category cost split available.</div>
|
<div class="text-muted small fst-italic">All items use fixed catalog pricing — no per-category cost split available.</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -1547,7 +1547,7 @@
|
|||||||
@if (jobPb.FacilityOverheadCost > 0)
|
@if (jobPb.FacilityOverheadCost > 0)
|
||||||
{
|
{
|
||||||
<div class="d-flex justify-content-between small mb-1">
|
<div class="d-flex justify-content-between small mb-1">
|
||||||
<span class="text-muted">Facility overhead (@jobPb.FacilityOverheadRatePerHour.ToString("C2")/hr � estimated hours)</span>
|
<span class="text-muted">Facility overhead (@jobPb.FacilityOverheadRatePerHour.ToString("C2")/hr × estimated hours)</span>
|
||||||
<span>@jobPb.FacilityOverheadCost.ToString("C")</span>
|
<span>@jobPb.FacilityOverheadCost.ToString("C")</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -1712,11 +1712,11 @@
|
|||||||
<div class="px-3 pt-3 pb-2">
|
<div class="px-3 pt-3 pb-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">
|
||||||
<span class="text-muted small">Revenue <span id="costingRevenueSource" class="badge bg-light text-secondary ms-1"></span></span>
|
<span class="text-muted small">Revenue <span id="costingRevenueSource" class="badge bg-light text-secondary ms-1"></span></span>
|
||||||
<span class="fw-semibold" id="costingRevenue">�</span>
|
<span class="fw-semibold" id="costingRevenue">—</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-content-between small text-muted mb-1 ps-2">
|
<div class="d-flex justify-content-between small text-muted mb-1 ps-2">
|
||||||
<span>Powder / Materials <a href="#" class="text-muted ms-1" onclick="costing.toggleDetail('powder');return false;"><i class="bi bi-chevron-down" id="powderChevron"></i></a></span>
|
<span>Powder / Materials <a href="#" class="text-muted ms-1" onclick="costing.toggleDetail('powder');return false;"><i class="bi bi-chevron-down" id="powderChevron"></i></a></span>
|
||||||
<span id="costingPowder">�</span>
|
<span id="costingPowder">—</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="powderDetail" style="display:none;" class="ps-3 pb-1">
|
<div id="powderDetail" style="display:none;" class="ps-3 pb-1">
|
||||||
<table class="table table-sm table-borderless mb-0" style="font-size:0.78rem;">
|
<table class="table table-sm table-borderless mb-0" style="font-size:0.78rem;">
|
||||||
@@ -1725,7 +1725,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-content-between small text-muted mb-1 ps-2">
|
<div class="d-flex justify-content-between small text-muted mb-1 ps-2">
|
||||||
<span>Labor (<span id="costingLaborHours">0</span> hrs) <a href="#" class="text-muted ms-1" onclick="costing.toggleDetail('labor');return false;"><i class="bi bi-chevron-down" id="laborChevron"></i></a></span>
|
<span>Labor (<span id="costingLaborHours">0</span> hrs) <a href="#" class="text-muted ms-1" onclick="costing.toggleDetail('labor');return false;"><i class="bi bi-chevron-down" id="laborChevron"></i></a></span>
|
||||||
<span id="costingLabor">�</span>
|
<span id="costingLabor">—</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="laborDetail" style="display:none;" class="ps-3 pb-1">
|
<div id="laborDetail" style="display:none;" class="ps-3 pb-1">
|
||||||
<table class="table table-sm table-borderless mb-0" style="font-size:0.78rem;">
|
<table class="table table-sm table-borderless mb-0" style="font-size:0.78rem;">
|
||||||
@@ -1734,12 +1734,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-content-between small text-muted mb-1 ps-2">
|
<div class="d-flex justify-content-between small text-muted mb-1 ps-2">
|
||||||
<span>Oven / Equipment <span id="costingOvenLabel" class="text-muted"></span></span>
|
<span>Oven / Equipment <span id="costingOvenLabel" class="text-muted"></span></span>
|
||||||
<span id="costingOven">�</span>
|
<span id="costingOven">—</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="costingReworkSection" style="display:none;">
|
<div id="costingReworkSection" style="display:none;">
|
||||||
<div class="d-flex justify-content-between small text-muted mb-1 ps-2">
|
<div class="d-flex justify-content-between small text-muted mb-1 ps-2">
|
||||||
<span>Rework Costs <a href="#" class="text-muted ms-1" onclick="costing.toggleDetail('rework');return false;"><i class="bi bi-chevron-down" id="reworkChevron"></i></a></span>
|
<span>Rework Costs <a href="#" class="text-muted ms-1" onclick="costing.toggleDetail('rework');return false;"><i class="bi bi-chevron-down" id="reworkChevron"></i></a></span>
|
||||||
<span id="costingRework">�</span>
|
<span id="costingRework">—</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="reworkDetail" style="display:none;" class="ps-3 pb-1">
|
<div id="reworkDetail" style="display:none;" class="ps-3 pb-1">
|
||||||
<table class="table table-sm table-borderless mb-0" style="font-size:0.78rem;">
|
<table class="table table-sm table-borderless mb-0" style="font-size:0.78rem;">
|
||||||
@@ -1748,25 +1748,25 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-content-between small text-success mb-1 ps-2">
|
<div class="d-flex justify-content-between small text-success mb-1 ps-2">
|
||||||
<span>Billed to Customer</span>
|
<span>Billed to Customer</span>
|
||||||
<span id="costingReworkBilled">�</span>
|
<span id="costingReworkBilled">—</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<hr class="my-2" />
|
<hr class="my-2" />
|
||||||
<div class="d-flex justify-content-between small mb-1 ps-2">
|
<div class="d-flex justify-content-between small mb-1 ps-2">
|
||||||
<span class="text-muted">Total Costs</span>
|
<span class="text-muted">Total Costs</span>
|
||||||
<span id="costingTotal" class="text-danger">�</span>
|
<span id="costingTotal" class="text-danger">—</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-content-between fw-bold mb-1">
|
<div class="d-flex justify-content-between fw-bold mb-1">
|
||||||
<span>Gross Profit</span>
|
<span>Gross Profit</span>
|
||||||
<span id="costingProfit">�</span>
|
<span id="costingProfit">—</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-content-between small text-muted mb-1">
|
<div class="d-flex justify-content-between small text-muted mb-1">
|
||||||
<span>Gross Margin</span>
|
<span>Gross Margin</span>
|
||||||
<span id="costingMargin">�</span>
|
<span id="costingMargin">—</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-content-between small text-muted">
|
<div class="d-flex justify-content-between small text-muted">
|
||||||
<span>Margin vs Quote</span>
|
<span>Margin vs Quote</span>
|
||||||
<span id="costingQuotedMargin">�</span>
|
<span id="costingQuotedMargin">—</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="costingNotes" class="px-3 pb-3" style="font-size:0.75rem;"></div>
|
<div id="costingNotes" class="px-3 pb-3" style="font-size:0.75rem;"></div>
|
||||||
@@ -1869,7 +1869,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Tags
|
<label class="form-label">Tags
|
||||||
<small class="text-muted fw-normal ms-1">� colors, finish, or other keywords</small>
|
<small class="text-muted fw-normal ms-1">– colors, finish, or other keywords</small>
|
||||||
</label>
|
</label>
|
||||||
<input type="hidden" id="photoTagsHidden" name="tags" />
|
<input type="hidden" id="photoTagsHidden" name="tags" />
|
||||||
<div id="photoTagsContainer"></div>
|
<div id="photoTagsContainer"></div>
|
||||||
@@ -1948,7 +1948,7 @@
|
|||||||
<textarea class="form-control" id="editPhotoCaption" rows="2" placeholder="Add a description or note..."></textarea>
|
<textarea class="form-control" id="editPhotoCaption" rows="2" placeholder="Add a description or note..."></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-0">
|
<div class="mb-0">
|
||||||
<label class="form-label fw-semibold">Tags <small class="text-muted fw-normal ms-1">� colors, finish, keywords</small></label>
|
<label class="form-label fw-semibold">Tags <small class="text-muted fw-normal ms-1">– colors, finish, keywords</small></label>
|
||||||
<input type="hidden" id="editPhotoTagsHidden" />
|
<input type="hidden" id="editPhotoTagsHidden" />
|
||||||
<div id="editPhotoTagsContainer"></div>
|
<div id="editPhotoTagsContainer"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2000,7 +2000,7 @@
|
|||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="form-label fw-semibold" for="smsMessageText">Message</label>
|
<label class="form-label fw-semibold" for="smsMessageText">Message</label>
|
||||||
<textarea class="form-control" id="smsMessageText" rows="5"
|
<textarea class="form-control" id="smsMessageText" rows="5"
|
||||||
placeholder="Type your message�" maxlength="160"></textarea>
|
placeholder="Type your message…" maxlength="160"></textarea>
|
||||||
<div class="d-flex justify-content-between mt-1">
|
<div class="d-flex justify-content-between mt-1">
|
||||||
<div id="smsStopWarning" class="text-warning small d-none">
|
<div id="smsStopWarning" class="text-warning small d-none">
|
||||||
<i class="bi bi-exclamation-triangle me-1"></i>"Reply STOP to opt out." will be appended automatically.
|
<i class="bi bi-exclamation-triangle me-1"></i>"Reply STOP to opt out." will be appended automatically.
|
||||||
@@ -2012,7 +2012,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-footer justify-content-between">
|
<div class="modal-footer justify-content-between">
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal" id="smsDismissBtn">
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal" id="smsDismissBtn">
|
||||||
Skip � don't send
|
Skip — don't send
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-info text-white" id="smsSendBtn">
|
<button type="button" class="btn btn-info text-white" id="smsSendBtn">
|
||||||
<i class="bi bi-send me-1"></i>Send SMS
|
<i class="bi bi-send me-1"></i>Send SMS
|
||||||
@@ -2068,98 +2068,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Surface Area Calculator Modal -->
|
@await Html.PartialAsync("_SqFtCalculatorModal")
|
||||||
<div class="modal fade" id="sqFtCalculatorModal" tabindex="-1">
|
@await Html.PartialAsync("_ItemWizardModal")
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title"><i class="bi bi-calculator me-2"></i>Surface Area Calculator <small class="text-muted">(per item)</small></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">Shape</label>
|
|
||||||
<select id="calcShape" class="form-select" onchange="toggleShapeInputs()">
|
|
||||||
<option value="rectangle">Rectangle / Square</option>
|
|
||||||
<option value="cylinder">Cylinder (Tube)</option>
|
|
||||||
<option value="circle">Circle (Flat)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div id="rectangleInputs">
|
|
||||||
<div class="row g-2">
|
|
||||||
<div class="col-6"><label class="form-label">Length (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
|
||||||
<input type="number" id="rectLength" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
|
||||||
<div class="col-6"><label class="form-label">Width (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
|
||||||
<input type="number" id="rectWidth" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
|
||||||
</div>
|
|
||||||
<small class="text-muted">Formula: L × W ÷ @(ViewBag.UseMetric == true ? "10,000" : "144")</small>
|
|
||||||
</div>
|
|
||||||
<div id="cylinderInputs" style="display:none">
|
|
||||||
<div class="row g-2">
|
|
||||||
<div class="col-6"><label class="form-label">Diameter (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
|
||||||
<input type="number" id="cylDiameter" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
|
||||||
<div class="col-6"><label class="form-label">Height (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
|
||||||
<input type="number" id="cylHeight" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="circleInputs" style="display:none">
|
|
||||||
<label class="form-label">Diameter (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
|
||||||
<input type="number" id="circDiameter" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()">
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<div class="alert alert-info alert-permanent mb-0"><strong>Result:</strong> <span id="calcResult">0.00</span> @ViewBag.AreaUnit</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<button type="button" class="btn btn-primary" onclick="useSqFtResult()">
|
|
||||||
<i class="bi bi-check-circle me-1"></i>Use This Value
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Item Wizard Modal -->
|
|
||||||
<div class="modal fade" id="itemWizardModal" tabindex="-1" data-bs-backdrop="static">
|
|
||||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<div class="d-flex flex-column">
|
|
||||||
<h5 class="modal-title mb-0" id="wizardTitle">Add Item</h5>
|
|
||||||
<div class="text-muted small mb-1" id="wizardStepTitle">Choose Item Type</div>
|
|
||||||
<div class="d-flex align-items-center gap-2" id="wizardStepIndicator">
|
|
||||||
<span class="wizard-step-dot active" data-step="1" title="Item Type"></span>
|
|
||||||
<div class="wizard-step-line"></div>
|
|
||||||
<span class="wizard-step-dot" data-step="2" title="Item Details"></span>
|
|
||||||
<div class="wizard-step-line" id="step2Line"></div>
|
|
||||||
<span class="wizard-step-dot" data-step="3" title="Coating Layers" id="step3Dot"></span>
|
|
||||||
<div class="wizard-step-line" id="step3Line"></div>
|
|
||||||
<span class="wizard-step-dot" data-step="4" title="Prep Services" id="step4Dot"></span>
|
|
||||||
<span class="text-muted small ms-2" id="wizardStepLabel">Step 1 of 4</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn-close ms-auto" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body" id="wizardBody" style="min-height: 300px;">
|
|
||||||
<!-- Content injected by JS -->
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer justify-content-between">
|
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<button type="button" class="btn btn-outline-secondary d-none" id="btnWizardBack" onclick="wizardBack()">
|
|
||||||
<i class="bi bi-arrow-left me-1"></i>Back
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-primary" id="btnWizardNext" onclick="wizardNext()">
|
|
||||||
Next <i class="bi bi-arrow-right ms-1"></i>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-success d-none" id="btnWizardSave" onclick="wizardSave()">
|
|
||||||
<i class="bi bi-check-lg me-1"></i>Add Item
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Embedded data for wizard JS -->
|
<!-- Embedded data for wizard JS -->
|
||||||
@if (ViewBag.InventoryCoatings != null)
|
@if (ViewBag.InventoryCoatings != null)
|
||||||
@@ -2223,7 +2133,7 @@
|
|||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Specific Item (optional)</label>
|
<label class="form-label">Specific Item (optional)</label>
|
||||||
<select class="form-select" id="rwJobItem">
|
<select class="form-select" id="rwJobItem">
|
||||||
<option value="">� Whole Job �</option>
|
<option value="">– Whole Job –</option>
|
||||||
@if (Model.Items != null)
|
@if (Model.Items != null)
|
||||||
{
|
{
|
||||||
@foreach (var item in Model.Items)
|
@foreach (var item in Model.Items)
|
||||||
@@ -2285,9 +2195,9 @@
|
|||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Resolution</label>
|
<label class="form-label">Resolution</label>
|
||||||
<select class="form-select" id="rwResolution">
|
<select class="form-select" id="rwResolution">
|
||||||
<option value="">� Pending �</option>
|
<option value="">– Pending –</option>
|
||||||
<option value="0">Recoated � No Charge</option>
|
<option value="0">Recoated — No Charge</option>
|
||||||
<option value="1">Recoated � Billed to Customer</option>
|
<option value="1">Recoated — Billed to Customer</option>
|
||||||
<option value="2">Customer Credited</option>
|
<option value="2">Customer Credited</option>
|
||||||
<option value="3">Written Off</option>
|
<option value="3">Written Off</option>
|
||||||
<option value="4">No Action Required</option>
|
<option value="4">No Action Required</option>
|
||||||
@@ -2346,7 +2256,7 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label fw-semibold">Worker <span class="text-danger">*</span></label>
|
<label class="form-label fw-semibold">Worker <span class="text-danger">*</span></label>
|
||||||
<select class="form-select" id="teWorkerId">
|
<select class="form-select" id="teWorkerId">
|
||||||
<option value="">� Select worker �</option>
|
<option value="">– Select worker –</option>
|
||||||
@foreach (var w in (ViewBag.ShopWorkers as IEnumerable<dynamic> ?? []))
|
@foreach (var w in (ViewBag.ShopWorkers as IEnumerable<dynamic> ?? []))
|
||||||
{
|
{
|
||||||
<option value="@w.Id">@w.Name</option>
|
<option value="@w.Id">@w.Name</option>
|
||||||
@@ -2365,7 +2275,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label fw-semibold">Stage / Task</label>
|
<label class="form-label fw-semibold">Stage / Task</label>
|
||||||
<input type="text" class="form-control" id="teStage" placeholder="e.g. Sandblasting, Coating, Masking�" list="stageOptions" />
|
<input type="text" class="form-control" id="teStage" placeholder="e.g. Sandblasting, Coating, Masking…" list="stageOptions" />
|
||||||
<datalist id="stageOptions">
|
<datalist id="stageOptions">
|
||||||
<option value="Sandblasting"></option>
|
<option value="Sandblasting"></option>
|
||||||
<option value="Masking & Taping"></option>
|
<option value="Masking & Taping"></option>
|
||||||
@@ -2380,7 +2290,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Notes</label>
|
<label class="form-label">Notes</label>
|
||||||
<textarea class="form-control" id="teNotes" rows="2" placeholder="Optional notes�"></textarea>
|
<textarea class="form-control" id="teNotes" rows="2" placeholder="Optional notes…"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-danger small d-none" id="teError"></div>
|
<div class="text-danger small d-none" id="teError"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2413,12 +2323,16 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@section Styles {
|
||||||
|
<link rel="stylesheet" href="~/css/item-wizard.css">
|
||||||
|
}
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
<link rel="stylesheet" href="~/css/job-photos.css" />
|
<link rel="stylesheet" href="~/css/job-photos.css" />
|
||||||
<script src="~/js/job-photos.js" asp-append-version="true"></script>
|
<script src="~/js/job-photos.js" asp-append-version="true"></script>
|
||||||
<script src="~/js/customer-change.js" asp-append-version="true"></script>
|
<script src="~/js/customer-change.js" asp-append-version="true"></script>
|
||||||
<script>
|
<script>
|
||||||
// ── Inline date editing ──────────────────────────────────────────────
|
// -- Inline date editing ----------------------------------------------
|
||||||
const jobId = @Model.Id;
|
const jobId = @Model.Id;
|
||||||
const antiForgeryToken = () => document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
const antiForgeryToken = () => document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||||
|
|
||||||
@@ -2513,38 +2427,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<style>
|
|
||||||
.wizard-step-dot {
|
|
||||||
width: 22px; height: 22px; border-radius: 50%;
|
|
||||||
background: #dee2e6; display: inline-block; cursor: default;
|
|
||||||
border: 2px solid #dee2e6; transition: all .2s; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.wizard-step-dot.active { background: #0d6efd; border-color: #0d6efd; }
|
|
||||||
.wizard-step-dot.done { background: #198754; border-color: #198754; }
|
|
||||||
.wizard-step-dot.skip { background: #adb5bd; border-color: #adb5bd; }
|
|
||||||
.wizard-step-line { flex: 1; height: 2px; background: #dee2e6; min-width: 30px; }
|
|
||||||
.item-type-card {
|
|
||||||
border: 2px solid #dee2e6; border-radius: .75rem; padding: 1.25rem 1rem;
|
|
||||||
cursor: pointer; transition: all .15s; text-align: center;
|
|
||||||
background: #fff; user-select: none;
|
|
||||||
}
|
|
||||||
.item-type-card:hover { border-color: #86b7fe; background: #f0f6ff; }
|
|
||||||
.item-type-card.selected { border-color: #0d6efd; background: #eef3ff; }
|
|
||||||
.item-type-card .item-type-icon { font-size: 2rem; margin-bottom: .5rem; }
|
|
||||||
.quote-item-card {
|
|
||||||
border: 1px solid #dee2e6; border-radius: .5rem;
|
|
||||||
padding: .75rem 1rem; margin-bottom: .5rem; background: #fafafa;
|
|
||||||
}
|
|
||||||
.quote-item-card .item-badge { font-size: .7rem; }
|
|
||||||
.coat-row { border: 1px solid #dee2e6; border-radius: .5rem; padding: .75rem; margin-bottom: .5rem; }
|
|
||||||
</style>
|
|
||||||
<script src="~/js/item-wizard.js?v=@DateTime.Now.Ticks"></script>
|
<script src="~/js/item-wizard.js?v=@DateTime.Now.Ticks"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
jobPhotoModule.init(@Model.Id, @Html.Raw(ViewBag.PhotoTagSuggestions ?? "[]"));
|
jobPhotoModule.init(@Model.Id, @Html.Raw(ViewBag.PhotoTagSuggestions ?? "[]"));
|
||||||
|
|
||||||
|
|
||||||
// ── Auto-submit after wizard saves an item ────────────────────────
|
// -- Auto-submit after wizard saves an item ------------------------
|
||||||
let itemsModified = false;
|
let itemsModified = false;
|
||||||
|
|
||||||
// Wrap wizardSave to set a flag before the modal hides
|
// Wrap wizardSave to set a flag before the modal hides
|
||||||
@@ -2562,12 +2451,12 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Delete confirmation modal ─────────────────────────────────────
|
// -- Delete confirmation modal -------------------------------------
|
||||||
let pendingDeleteItemId = -1;
|
let pendingDeleteItemId = -1;
|
||||||
const deleteModal = new bootstrap.Modal(document.getElementById('deleteConfirmModal'));
|
const deleteModal = new bootstrap.Modal(document.getElementById('deleteConfirmModal'));
|
||||||
const deleteItemToken = document.querySelector('input[name="__RequestVerificationToken"]').value;
|
const deleteItemToken = document.querySelector('input[name="__RequestVerificationToken"]').value;
|
||||||
|
|
||||||
// Delegated listener � handles all delete buttons via data attributes
|
// Delegated listener -- handles all delete buttons via data attributes
|
||||||
document.addEventListener('click', function (e) {
|
document.addEventListener('click', function (e) {
|
||||||
const btn = e.target.closest('[data-delete-id]');
|
const btn = e.target.closest('[data-delete-id]');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
@@ -2600,7 +2489,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- ── Rework / Warranty ────────────────────────────────────────────── -->
|
<!-- -- Rework / Warranty ---------------------------------------------- -->
|
||||||
<script>
|
<script>
|
||||||
const rework = (() => {
|
const rework = (() => {
|
||||||
const jid = @Model.Id;
|
const jid = @Model.Id;
|
||||||
@@ -2645,12 +2534,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="small mt-1 text-muted">${r.defectDescription}</div>
|
<div class="small mt-1 text-muted">${r.defectDescription}</div>
|
||||||
<div class="small text-muted mt-1">
|
<div class="small text-muted mt-1">
|
||||||
Found: ${r.discoveredByDisplay} � ${new Date(r.discoveredDate).toLocaleDateString()}
|
Found: ${r.discoveredByDisplay} — ${new Date(r.discoveredDate).toLocaleDateString()}
|
||||||
${r.reportedByName ? '� ' + r.reportedByName : ''}
|
${r.reportedByName ? '– ' + r.reportedByName : ''}
|
||||||
${r.jobItemDescription ? ' | Item: ' + r.jobItemDescription : ''}
|
${r.jobItemDescription ? ' | Item: ' + r.jobItemDescription : ''}
|
||||||
</div>
|
</div>
|
||||||
${r.reworkJobNumber ? `<div class="small mt-1"><i class="bi bi-briefcase me-1"></i>Rework Job: <a href="/Jobs/Details/${r.reworkJobId}" class="text-decoration-none fw-semibold">${r.reworkJobNumber}</a></div>` : ''}
|
${r.reworkJobNumber ? `<div class="small mt-1"><i class="bi bi-briefcase me-1"></i>Rework Job: <a href="/Jobs/Details/${r.reworkJobId}" class="text-decoration-none fw-semibold">${r.reworkJobNumber}</a></div>` : ''}
|
||||||
${r.resolutionDisplay ? `<div class="small text-success mt-1"><i class="bi bi-check-circle me-1"></i>${r.resolutionDisplay}${r.actualReworkCost > 0 ? ' � $' + r.actualReworkCost.toFixed(2) : ''}</div>` : ''}
|
${r.resolutionDisplay ? `<div class="small text-success mt-1"><i class="bi bi-check-circle me-1"></i>${r.resolutionDisplay}${r.actualReworkCost > 0 ? ' — $' + r.actualReworkCost.toFixed(2) : ''}</div>` : ''}
|
||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2756,7 +2645,7 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- ── Job Costing ──────────────────────────────────────────────────── -->
|
<!-- -- Job Costing ---------------------------------------------------- -->
|
||||||
<script>
|
<script>
|
||||||
const costing = (() => {
|
const costing = (() => {
|
||||||
const jid = @Model.Id;
|
const jid = @Model.Id;
|
||||||
@@ -2796,7 +2685,7 @@
|
|||||||
document.getElementById('costingReworkBilled').textContent = fmt(d.reworkBilledToCustomer);
|
document.getElementById('costingReworkBilled').textContent = fmt(d.reworkBilledToCustomer);
|
||||||
const rBody = document.getElementById('reworkCostLines');
|
const rBody = document.getElementById('reworkCostLines');
|
||||||
rBody.innerHTML = d.reworkLines.map(l => `<tr>
|
rBody.innerHTML = d.reworkLines.map(l => `<tr>
|
||||||
<td class="text-muted">${l.jobNumber ? `<a href="/Jobs/Details" class="text-decoration-none">${l.jobNumber}</a>` : 'No job'} � ${l.reason}${l.isEstimate ? ' <span class="badge bg-secondary" style="font-size:0.65rem;">est.</span>' : ''}</td>
|
<td class="text-muted">${l.jobNumber ? `<a href="/Jobs/Details" class="text-decoration-none">${l.jobNumber}</a>` : 'No job'} – ${l.reason}${l.isEstimate ? ' <span class="badge bg-secondary" style="font-size:0.65rem;">est.</span>' : ''}</td>
|
||||||
<td class="text-end text-nowrap">${l.billedToCustomer > 0 ? `<span class="text-success">${fmt(l.billedToCustomer)} billed</span>` : 'absorbed'}</td>
|
<td class="text-end text-nowrap">${l.billedToCustomer > 0 ? `<span class="text-success">${fmt(l.billedToCustomer)} billed</span>` : 'absorbed'}</td>
|
||||||
<td class="text-end text-nowrap fw-semibold">${fmt(l.cost)}</td></tr>`).join('');
|
<td class="text-end text-nowrap fw-semibold">${fmt(l.cost)}</td></tr>`).join('');
|
||||||
} else {
|
} else {
|
||||||
@@ -2812,14 +2701,14 @@
|
|||||||
|
|
||||||
document.getElementById('costingMargin').textContent = `${d.grossMargin}%`;
|
document.getElementById('costingMargin').textContent = `${d.grossMargin}%`;
|
||||||
document.getElementById('costingQuotedMargin').textContent =
|
document.getElementById('costingQuotedMargin').textContent =
|
||||||
d.quotedPrice > 0 ? `${d.quotedMargin}% (quoted ${fmt(d.quotedPrice)})` : '�';
|
d.quotedPrice > 0 ? `${d.quotedMargin}% (quoted ${fmt(d.quotedPrice)})` : '—';
|
||||||
|
|
||||||
// Powder detail lines
|
// Powder detail lines
|
||||||
const pBody = document.getElementById('powderLines');
|
const pBody = document.getElementById('powderLines');
|
||||||
pBody.innerHTML = d.hasPowderData
|
pBody.innerHTML = d.hasPowderData
|
||||||
? d.powderLines.map(l => `<tr>
|
? d.powderLines.map(l => `<tr>
|
||||||
<td class="text-muted" style="max-width:160px;white-space:normal;">${l.description}${l.isActual ? ' <span class="badge bg-success" style="font-size:0.65rem;">actual</span>' : ''}</td>
|
<td class="text-muted" style="max-width:160px;white-space:normal;">${l.description}${l.isActual ? ' <span class="badge bg-success" style="font-size:0.65rem;">actual</span>' : ''}</td>
|
||||||
<td class="text-end text-nowrap">${l.lbs} lbs � ${fmt(l.costPerLb)}/lb</td>
|
<td class="text-end text-nowrap">${l.lbs} lbs × ${fmt(l.costPerLb)}/lb</td>
|
||||||
<td class="text-end text-nowrap fw-semibold">${fmt(l.total)}</td></tr>`).join('')
|
<td class="text-end text-nowrap fw-semibold">${fmt(l.total)}</td></tr>`).join('')
|
||||||
: '<tr><td colspan="3" class="text-muted">No powder cost data on coats.</td></tr>';
|
: '<tr><td colspan="3" class="text-muted">No powder cost data on coats.</td></tr>';
|
||||||
|
|
||||||
@@ -2827,14 +2716,14 @@
|
|||||||
const lBody = document.getElementById('laborLines');
|
const lBody = document.getElementById('laborLines');
|
||||||
lBody.innerHTML = d.hasLaborData
|
lBody.innerHTML = d.hasLaborData
|
||||||
? d.laborLines.map(l => `<tr>
|
? d.laborLines.map(l => `<tr>
|
||||||
<td class="text-muted">${l.worker}${l.stage ? ' � ' + l.stage : ''}<br/><small>${l.workDate}</small></td>
|
<td class="text-muted">${l.worker}${l.stage ? ' – ' + l.stage : ''}<br/><small>${l.workDate}</small></td>
|
||||||
<td class="text-end text-nowrap">${l.hours}h � ${fmt(l.rate)}/hr${l.usingFallback ? ' <span title="Using standard labor rate" class="text-muted">*</span>' : ''}</td>
|
<td class="text-end text-nowrap">${l.hours}h × ${fmt(l.rate)}/hr${l.usingFallback ? ' <span title="Using standard labor rate" class="text-muted">*</span>' : ''}</td>
|
||||||
<td class="text-end text-nowrap fw-semibold">${fmt(l.total)}</td></tr>`).join('')
|
<td class="text-end text-nowrap fw-semibold">${fmt(l.total)}</td></tr>`).join('')
|
||||||
: '<tr><td colspan="3" class="text-muted">No time entries logged yet.</td></tr>';
|
: '<tr><td colspan="3" class="text-muted">No time entries logged yet.</td></tr>';
|
||||||
|
|
||||||
// Notes
|
// Notes
|
||||||
const notes = [];
|
const notes = [];
|
||||||
if (!d.hasPowderData && d.hasPowderRateButNoQty) notes.push('? Surface area not set on one or more items � edit the item and enter a surface area to calculate powder cost.');
|
if (!d.hasPowderData && d.hasPowderRateButNoQty) notes.push('? Surface area not set on one or more items — edit the item and enter a surface area to calculate powder cost.');
|
||||||
else if (!d.hasPowderData) notes.push('? Add powder cost per lb on coat records to include material cost.');
|
else if (!d.hasPowderData) notes.push('? Add powder cost per lb on coat records to include material cost.');
|
||||||
if (!d.hasLaborData) notes.push('? Log time entries to include labor cost.');
|
if (!d.hasLaborData) notes.push('? Log time entries to include labor cost.');
|
||||||
if (d.laborLines?.some(l => l.usingFallback)) notes.push('* One or more workers using standard labor rate fallback.');
|
if (d.laborLines?.some(l => l.usingFallback)) notes.push('* One or more workers using standard labor rate fallback.');
|
||||||
@@ -2865,7 +2754,7 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- ── Time Tracking ─────────────────────────────────────────────────── -->
|
<!-- -- Time Tracking --------------------------------------------------- -->
|
||||||
<script>
|
<script>
|
||||||
const timeTracking = (() => {
|
const timeTracking = (() => {
|
||||||
const jid = @Model.Id;
|
const jid = @Model.Id;
|
||||||
@@ -2873,7 +2762,7 @@
|
|||||||
const modal = new bootstrap.Modal(document.getElementById('timeEntryModal'));
|
const modal = new bootstrap.Modal(document.getElementById('timeEntryModal'));
|
||||||
let entries = [];
|
let entries = [];
|
||||||
|
|
||||||
// ── Load ──────────────────────────────────────────────────────────
|
// -- Load ----------------------------------------------------------
|
||||||
async function load() {
|
async function load() {
|
||||||
const r = await fetch(`/Jobs/GetTimeEntries?jobId=${jid}`);
|
const r = await fetch(`/Jobs/GetTimeEntries?jobId=${jid}`);
|
||||||
entries = await r.json();
|
entries = await r.json();
|
||||||
@@ -2904,7 +2793,7 @@
|
|||||||
<td class="fw-semibold">${esc(e.workerName)}</td>
|
<td class="fw-semibold">${esc(e.workerName)}</td>
|
||||||
<td class="small">${d}</td>
|
<td class="small">${d}</td>
|
||||||
<td class="text-end fw-semibold">${e.hoursWorked.toFixed(2)}</td>
|
<td class="text-end fw-semibold">${e.hoursWorked.toFixed(2)}</td>
|
||||||
<td class="small">${e.stage ? `<span class="badge bg-secondary-subtle text-secondary">${esc(e.stage)}</span>` : '<span class="text-muted">�</span>'}</td>
|
<td class="small">${e.stage ? `<span class="badge bg-secondary-subtle text-secondary">${esc(e.stage)}</span>` : '<span class="text-muted">—</span>'}</td>
|
||||||
<td class="small text-muted">${esc(e.notes ?? '')}</td>
|
<td class="small text-muted">${esc(e.notes ?? '')}</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<button class="btn btn-xs btn-outline-secondary me-1 py-0 px-1" title="Edit" onclick="timeTracking.openEdit(${e.id})"><i class="bi bi-pencil"></i></button>
|
<button class="btn btn-xs btn-outline-secondary me-1 py-0 px-1" title="Edit" onclick="timeTracking.openEdit(${e.id})"><i class="bi bi-pencil"></i></button>
|
||||||
@@ -2916,12 +2805,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateTotals(total) {
|
function updateTotals(total) {
|
||||||
const fmt = total > 0 ? total.toFixed(2) + ' hrs' : '�';
|
const fmt = total > 0 ? total.toFixed(2) + ' hrs' : '—';
|
||||||
document.getElementById('totalHoursDisplay').textContent = fmt;
|
document.getElementById('totalHoursDisplay').textContent = fmt;
|
||||||
document.getElementById('timeEntriesTotalHours').textContent = total > 0 ? total.toFixed(2) : '�';
|
document.getElementById('timeEntriesTotalHours').textContent = total > 0 ? total.toFixed(2) : '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Modal helpers ─────────────────────────────────────────────────
|
// -- Modal helpers -------------------------------------------------
|
||||||
function openAdd() {
|
function openAdd() {
|
||||||
document.getElementById('timeEntryModalTitle').textContent = 'Log Time';
|
document.getElementById('timeEntryModalTitle').textContent = 'Log Time';
|
||||||
document.getElementById('teEntryId').value = '0';
|
document.getElementById('teEntryId').value = '0';
|
||||||
@@ -3028,7 +2917,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Deposits ─────────────────────────────────────────────────────────────
|
// -- Deposits -------------------------------------------------------------
|
||||||
// Note: antiForgeryToken() is already defined above in this script block
|
// Note: antiForgeryToken() is already defined above in this script block
|
||||||
document.getElementById('addDepositForm')?.addEventListener('submit', async function(e) {
|
document.getElementById('addDepositForm')?.addEventListener('submit', async function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -3042,7 +2931,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (errEl) errEl.classList.add('d-none');
|
if (errEl) errEl.classList.add('d-none');
|
||||||
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving�'; }
|
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving…'; }
|
||||||
|
|
||||||
const params = new URLSearchParams(new FormData(form));
|
const params = new URLSearchParams(new FormData(form));
|
||||||
|
|
||||||
@@ -3084,7 +2973,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Collapsible sections ──────────────────────────────────────────────────
|
// -- Collapsible sections --------------------------------------------------
|
||||||
(function () {
|
(function () {
|
||||||
const storageKey = 'jobDetailCollapse_@Model.Id';
|
const storageKey = 'jobDetailCollapse_@Model.Id';
|
||||||
const sections = ['collapseTimeTracking', 'collapsePartIntake', 'collapsePhotos', 'collapseDeposits', 'collapseMaterials'];
|
const sections = ['collapseTimeTracking', 'collapsePartIntake', 'collapsePhotos', 'collapseDeposits', 'collapseMaterials'];
|
||||||
@@ -3123,7 +3012,7 @@
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// ── Part Intake Modal ─────────────────────────────────────────────────────
|
// -- Part Intake Modal --------------------------------------------------
|
||||||
(function () {
|
(function () {
|
||||||
const expectedCount = @intakeExpectedCount;
|
const expectedCount = @intakeExpectedCount;
|
||||||
const partCountInput = document.getElementById('intakePartCount');
|
const partCountInput = document.getElementById('intakePartCount');
|
||||||
@@ -3216,7 +3105,7 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label fw-semibold">Template Name <span class="text-danger">*</span></label>
|
<label class="form-label fw-semibold">Template Name <span class="text-danger">*</span></label>
|
||||||
<input type="text" name="templateName" class="form-control" required maxlength="100"
|
<input type="text" name="templateName" class="form-control" required maxlength="100"
|
||||||
placeholder="e.g. Wheel Refinish � Standard 4pc">
|
placeholder="e.g. Wheel Refinish — Standard 4pc">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@model PowderCoating.Application.DTOs.Job.UpdateJobDto
|
@model PowderCoating.Application.DTOs.Job.UpdateJobDto
|
||||||
@using PowderCoating.Core.Entities
|
@using PowderCoating.Core.Entities
|
||||||
|
|
||||||
@{
|
@{
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
<a tabindex="0" class="help-icon" role="button"
|
<a tabindex="0" class="help-icon" role="button"
|
||||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||||
data-bs-title="Job Status"
|
data-bs-title="Job Status"
|
||||||
data-bs-content="Tracks where the job is in the workflow: Pending → Approved → Sandblasting → Cleaning → Coating → Curing → QualityCheck → Completed → ReadyForPickup → Delivered. Status changes trigger customer email notifications (if enabled). Use OnHold to pause work without losing progress.">
|
data-bs-content="Tracks where the job is in the workflow: Pending → Approved → Sandblasting → Cleaning → Coating → Curing → QualityCheck → Completed → ReadyForPickup → Delivered. Status changes trigger customer email notifications (if enabled). Use OnHold to pause work without losing progress.">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
</label>
|
</label>
|
||||||
@@ -298,96 +298,8 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Surface Area Calculator Modal -->
|
@await Html.PartialAsync("_SqFtCalculatorModal")
|
||||||
<div class="modal fade" id="sqFtCalculatorModal" tabindex="-1">
|
@await Html.PartialAsync("_ItemWizardModal")
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title"><i class="bi bi-calculator me-2"></i>Surface Area Calculator <small class="text-muted">(per item)</small></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">Shape</label>
|
|
||||||
<select id="calcShape" class="form-select" onchange="toggleShapeInputs()">
|
|
||||||
<option value="rectangle">Rectangle / Square</option>
|
|
||||||
<option value="cylinder">Cylinder (Tube)</option>
|
|
||||||
<option value="circle">Circle (Flat)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div id="rectangleInputs">
|
|
||||||
<div class="row g-2">
|
|
||||||
<div class="col-6"><label class="form-label">Length (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
|
||||||
<input type="number" id="rectLength" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
|
||||||
<div class="col-6"><label class="form-label">Width (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
|
||||||
<input type="number" id="rectWidth" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
|
||||||
</div>
|
|
||||||
<small class="text-muted">Formula: L × W ÷ @(ViewBag.UseMetric == true ? "10,000" : "144")</small>
|
|
||||||
</div>
|
|
||||||
<div id="cylinderInputs" style="display:none">
|
|
||||||
<div class="row g-2">
|
|
||||||
<div class="col-6"><label class="form-label">Diameter (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
|
||||||
<input type="number" id="cylDiameter" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
|
||||||
<div class="col-6"><label class="form-label">Height (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
|
||||||
<input type="number" id="cylHeight" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="circleInputs" style="display:none">
|
|
||||||
<label class="form-label">Diameter (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
|
||||||
<input type="number" id="circDiameter" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()">
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<div class="alert alert-info alert-permanent mb-0"><strong>Result:</strong> <span id="calcResult">0.00</span> @ViewBag.AreaUnit</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<button type="button" class="btn btn-primary" onclick="useSqFtResult()">
|
|
||||||
<i class="bi bi-check-circle me-1"></i>Use This Value
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Item Wizard Modal -->
|
|
||||||
<div class="modal fade" id="itemWizardModal" tabindex="-1" data-bs-backdrop="static">
|
|
||||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<div class="d-flex flex-column">
|
|
||||||
<h5 class="modal-title mb-0" id="wizardTitle">Add Item</h5>
|
|
||||||
<div class="text-muted small mb-1" id="wizardStepTitle">Choose Item Type</div>
|
|
||||||
<div class="d-flex align-items-center gap-2" id="wizardStepIndicator">
|
|
||||||
<span class="wizard-step-dot active" data-step="1" title="Item Type"></span>
|
|
||||||
<div class="wizard-step-line"></div>
|
|
||||||
<span class="wizard-step-dot" data-step="2" title="Item Details"></span>
|
|
||||||
<div class="wizard-step-line" id="step2Line"></div>
|
|
||||||
<span class="wizard-step-dot" data-step="3" title="Coating Layers" id="step3Dot"></span>
|
|
||||||
<div class="wizard-step-line" id="step3Line"></div>
|
|
||||||
<span class="wizard-step-dot" data-step="4" title="Prep Services" id="step4Dot"></span>
|
|
||||||
<span class="text-muted small ms-2" id="wizardStepLabel">Step 1 of 4</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn-close ms-auto" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body" id="wizardBody" style="min-height: 300px;"></div>
|
|
||||||
<div class="modal-footer justify-content-between">
|
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<button type="button" class="btn btn-outline-secondary d-none" id="btnWizardBack" onclick="wizardBack()">
|
|
||||||
<i class="bi bi-arrow-left me-1"></i>Back
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-primary" id="btnWizardNext" onclick="wizardNext()">
|
|
||||||
Next <i class="bi bi-arrow-right ms-1"></i>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-success d-none" id="btnWizardSave" onclick="wizardSave()">
|
|
||||||
<i class="bi bi-check-lg me-1"></i>Add Item
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Embedded data for JS -->
|
<!-- Embedded data for JS -->
|
||||||
@if (ViewBag.InventoryCoatings != null)
|
@if (ViewBag.InventoryCoatings != null)
|
||||||
@@ -428,6 +340,7 @@
|
|||||||
complexity = item.Complexity,
|
complexity = item.Complexity,
|
||||||
isGenericItem = item.IsGenericItem,
|
isGenericItem = item.IsGenericItem,
|
||||||
isLaborItem = item.IsLaborItem,
|
isLaborItem = item.IsLaborItem,
|
||||||
|
isAiItem = item.IsAiItem,
|
||||||
requiresSandblasting = item.RequiresSandblasting,
|
requiresSandblasting = item.RequiresSandblasting,
|
||||||
requiresMasking = item.RequiresMasking,
|
requiresMasking = item.RequiresMasking,
|
||||||
notes = item.Notes,
|
notes = item.Notes,
|
||||||
@@ -475,41 +388,7 @@
|
|||||||
|
|
||||||
@section Styles {
|
@section Styles {
|
||||||
<link rel="stylesheet" href="~/lib/tom-select/css/tom-select.bootstrap5.min.css">
|
<link rel="stylesheet" href="~/lib/tom-select/css/tom-select.bootstrap5.min.css">
|
||||||
<style>
|
<link rel="stylesheet" href="~/css/item-wizard.css">
|
||||||
.wizard-step-dot {
|
|
||||||
width: 22px; height: 22px; border-radius: 50%;
|
|
||||||
background: #dee2e6; display: inline-block; cursor: default;
|
|
||||||
border: 2px solid #dee2e6; transition: all .2s; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.wizard-step-dot.active { background: #0d6efd; border-color: #0d6efd; }
|
|
||||||
.wizard-step-dot.done { background: #198754; border-color: #198754; }
|
|
||||||
.wizard-step-dot.skip { background: #adb5bd; border-color: #adb5bd; }
|
|
||||||
.wizard-step-line { flex: 1; height: 2px; background: #dee2e6; min-width: 30px; }
|
|
||||||
.item-type-card {
|
|
||||||
border: 2px solid #dee2e6; border-radius: .75rem; padding: 1.25rem 1rem;
|
|
||||||
cursor: pointer; transition: all .15s; text-align: center;
|
|
||||||
background: #fff; user-select: none;
|
|
||||||
}
|
|
||||||
.item-type-card:hover { border-color: #86b7fe; background: #f0f6ff; }
|
|
||||||
.item-type-card.selected { border-color: #0d6efd; background: #eef3ff; }
|
|
||||||
.item-type-card .item-type-icon { font-size: 2rem; margin-bottom: .5rem; }
|
|
||||||
[data-bs-theme="dark"] .item-type-card { background: var(--bs-tertiary-bg); border-color: var(--bs-border-color); color: var(--bs-body-color); }
|
|
||||||
[data-bs-theme="dark"] .item-type-card:hover { border-color: #86b7fe; background: var(--bs-secondary-bg); }
|
|
||||||
[data-bs-theme="dark"] .item-type-card.selected { border-color: #0d6efd; background: #1a2a4a; }
|
|
||||||
.catalog-list-item { cursor: pointer; border-bottom: 1px solid var(--bs-border-color); font-size: .9rem; transition: background .1s; }
|
|
||||||
.catalog-list-item:last-child { border-bottom: none; }
|
|
||||||
.catalog-list-item:hover { background: var(--bs-tertiary-bg); }
|
|
||||||
.catalog-list-item.selected { background: #eef3ff; color: #0d6efd; font-weight: 600; }
|
|
||||||
[data-bs-theme="dark"] .catalog-list-item.selected { background: #1a2a4a; color: #86b7fe; }
|
|
||||||
.quote-item-card {
|
|
||||||
border: 1px solid #dee2e6; border-radius: .5rem;
|
|
||||||
padding: .75rem 1rem; margin-bottom: .5rem; background: #fafafa;
|
|
||||||
}
|
|
||||||
.quote-item-card .item-badge { font-size: .7rem; }
|
|
||||||
.coat-row { border: 1px solid #dee2e6; border-radius: .5rem; padding: .75rem; margin-bottom: .5rem; }
|
|
||||||
[data-bs-theme="dark"] .quote-item-card { background: var(--bs-tertiary-bg); border-color: var(--bs-border-color); color: var(--bs-body-color); }
|
|
||||||
[data-bs-theme="dark"] .coat-row { background: var(--bs-tertiary-bg); border-color: var(--bs-border-color); }
|
|
||||||
</style>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@model PowderCoating.Application.DTOs.Job.JobEditItemsViewModel
|
@model PowderCoating.Application.DTOs.Job.JobEditItemsViewModel
|
||||||
@using PowderCoating.Core.Entities
|
@using PowderCoating.Core.Entities
|
||||||
|
|
||||||
@{
|
@{
|
||||||
@@ -19,6 +19,9 @@
|
|||||||
<input type="hidden" name="JobNumber" value="@Model.JobNumber" />
|
<input type="hidden" name="JobNumber" value="@Model.JobNumber" />
|
||||||
<input type="hidden" name="CustomerId" value="@Model.CustomerId" />
|
<input type="hidden" name="CustomerId" value="@Model.CustomerId" />
|
||||||
<input type="hidden" name="TaxPercent" value="@Model.TaxPercent" />
|
<input type="hidden" name="TaxPercent" value="@Model.TaxPercent" />
|
||||||
|
<input type="hidden" name="OvenCostId" value="@Model.OvenCostId" />
|
||||||
|
<input type="hidden" name="OvenBatches" value="@Model.OvenBatches" />
|
||||||
|
<input type="hidden" name="OvenCycleMinutes" value="@Model.OvenCycleMinutes" />
|
||||||
|
|
||||||
@if (!ViewData.ModelState.IsValid)
|
@if (!ViewData.ModelState.IsValid)
|
||||||
{
|
{
|
||||||
@@ -94,98 +97,8 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Surface Area Calculator Modal -->
|
@await Html.PartialAsync("_SqFtCalculatorModal")
|
||||||
<div class="modal fade" id="sqFtCalculatorModal" tabindex="-1">
|
@await Html.PartialAsync("_ItemWizardModal")
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title"><i class="bi bi-calculator me-2"></i>Surface Area Calculator <small class="text-muted">(per item)</small></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">Shape</label>
|
|
||||||
<select id="calcShape" class="form-select" onchange="toggleShapeInputs()">
|
|
||||||
<option value="rectangle">Rectangle / Square</option>
|
|
||||||
<option value="cylinder">Cylinder (Tube)</option>
|
|
||||||
<option value="circle">Circle (Flat)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div id="rectangleInputs">
|
|
||||||
<div class="row g-2">
|
|
||||||
<div class="col-6"><label class="form-label">Length (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
|
||||||
<input type="number" id="rectLength" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
|
||||||
<div class="col-6"><label class="form-label">Width (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
|
||||||
<input type="number" id="rectWidth" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
|
||||||
</div>
|
|
||||||
<small class="text-muted">Formula: L × W ÷ @(ViewBag.UseMetric == true ? "10,000" : "144")</small>
|
|
||||||
</div>
|
|
||||||
<div id="cylinderInputs" style="display:none">
|
|
||||||
<div class="row g-2">
|
|
||||||
<div class="col-6"><label class="form-label">Diameter (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
|
||||||
<input type="number" id="cylDiameter" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
|
||||||
<div class="col-6"><label class="form-label">Height (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
|
||||||
<input type="number" id="cylHeight" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="circleInputs" style="display:none">
|
|
||||||
<label class="form-label">Diameter (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
|
||||||
<input type="number" id="circDiameter" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()">
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<div class="alert alert-info alert-permanent mb-0"><strong>Result:</strong> <span id="calcResult">0.00</span> @ViewBag.AreaUnit</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<button type="button" class="btn btn-primary" onclick="useSqFtResult()">
|
|
||||||
<i class="bi bi-check-circle me-1"></i>Use This Value
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ========================= ITEM WIZARD MODAL ========================= -->
|
|
||||||
<div class="modal fade" id="itemWizardModal" tabindex="-1" data-bs-backdrop="static">
|
|
||||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<div class="d-flex flex-column">
|
|
||||||
<h5 class="modal-title mb-0" id="wizardTitle">Add Item</h5>
|
|
||||||
<div class="text-muted small mb-1" id="wizardStepTitle">Choose Item Type</div>
|
|
||||||
<div class="d-flex align-items-center gap-2" id="wizardStepIndicator">
|
|
||||||
<span class="wizard-step-dot active" data-step="1" title="Item Type"></span>
|
|
||||||
<div class="wizard-step-line"></div>
|
|
||||||
<span class="wizard-step-dot" data-step="2" title="Item Details"></span>
|
|
||||||
<div class="wizard-step-line" id="step2Line"></div>
|
|
||||||
<span class="wizard-step-dot" data-step="3" title="Coating Layers" id="step3Dot"></span>
|
|
||||||
<div class="wizard-step-line" id="step3Line"></div>
|
|
||||||
<span class="wizard-step-dot" data-step="4" title="Prep Services" id="step4Dot"></span>
|
|
||||||
<span class="text-muted small ms-2" id="wizardStepLabel">Step 1 of 4</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn-close ms-auto" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body" id="wizardBody" style="min-height: 300px;">
|
|
||||||
<!-- Content injected by JS -->
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer justify-content-between">
|
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<button type="button" class="btn btn-outline-secondary d-none" id="btnWizardBack" onclick="wizardBack()">
|
|
||||||
<i class="bi bi-arrow-left me-1"></i>Back
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-primary" id="btnWizardNext" onclick="wizardNext()">
|
|
||||||
Next <i class="bi bi-arrow-right ms-1"></i>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-success d-none" id="btnWizardSave" onclick="wizardSave()">
|
|
||||||
<i class="bi bi-check-lg me-1"></i>Add Item
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Embedded data for JS -->
|
<!-- Embedded data for JS -->
|
||||||
@if (ViewBag.InventoryCoatings != null)
|
@if (ViewBag.InventoryCoatings != null)
|
||||||
@@ -223,6 +136,7 @@
|
|||||||
complexity = item.Complexity,
|
complexity = item.Complexity,
|
||||||
isGenericItem = item.IsGenericItem,
|
isGenericItem = item.IsGenericItem,
|
||||||
isLaborItem = item.IsLaborItem,
|
isLaborItem = item.IsLaborItem,
|
||||||
|
isAiItem = item.IsAiItem,
|
||||||
requiresSandblasting = item.RequiresSandblasting,
|
requiresSandblasting = item.RequiresSandblasting,
|
||||||
requiresMasking = item.RequiresMasking,
|
requiresMasking = item.RequiresMasking,
|
||||||
notes = item.Notes,
|
notes = item.Notes,
|
||||||
@@ -256,7 +170,7 @@
|
|||||||
"discountType": "None",
|
"discountType": "None",
|
||||||
"discountValue": 0,
|
"discountValue": 0,
|
||||||
"isRushJob": false,
|
"isRushJob": false,
|
||||||
"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")",
|
||||||
@@ -266,42 +180,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
@section Styles {
|
@section Styles {
|
||||||
<style>
|
<link rel="stylesheet" href="~/css/item-wizard.css">
|
||||||
/* Wizard step indicator */
|
|
||||||
.wizard-step-dot {
|
|
||||||
width: 22px; height: 22px; border-radius: 50%;
|
|
||||||
background: #dee2e6; display: inline-block; cursor: default;
|
|
||||||
border: 2px solid #dee2e6; transition: all .2s;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.wizard-step-dot.active { background: #0d6efd; border-color: #0d6efd; }
|
|
||||||
.wizard-step-dot.done { background: #198754; border-color: #198754; }
|
|
||||||
.wizard-step-dot.skip { background: #adb5bd; border-color: #adb5bd; }
|
|
||||||
.wizard-step-line { flex: 1; height: 2px; background: #dee2e6; min-width: 30px; }
|
|
||||||
/* Item type picker cards */
|
|
||||||
.item-type-card {
|
|
||||||
border: 2px solid #dee2e6; border-radius: .75rem; padding: 1.25rem 1rem;
|
|
||||||
cursor: pointer; transition: all .15s; text-align: center;
|
|
||||||
background: #fff; user-select: none;
|
|
||||||
}
|
|
||||||
.item-type-card:hover { border-color: #86b7fe; background: #f0f6ff; }
|
|
||||||
.item-type-card.selected { border-color: #0d6efd; background: #eef3ff; }
|
|
||||||
.item-type-card .item-type-icon { font-size: 2rem; margin-bottom: .5rem; }
|
|
||||||
.catalog-list-item { cursor: pointer; border-bottom: 1px solid var(--bs-border-color); font-size: .9rem; transition: background .1s; }
|
|
||||||
.catalog-list-item:last-child { border-bottom: none; }
|
|
||||||
.catalog-list-item:hover { background: var(--bs-tertiary-bg); }
|
|
||||||
.catalog-list-item.selected { background: #eef3ff; color: #0d6efd; font-weight: 600; }
|
|
||||||
[data-bs-theme="dark"] .catalog-list-item.selected { background: #1a2a4a; color: #86b7fe; }
|
|
||||||
/* Summary cards */
|
|
||||||
.quote-item-card {
|
|
||||||
border: 1px solid #dee2e6; border-radius: .5rem;
|
|
||||||
padding: .75rem 1rem; margin-bottom: .5rem;
|
|
||||||
background: #fafafa;
|
|
||||||
}
|
|
||||||
.quote-item-card .item-badge { font-size: .7rem; }
|
|
||||||
/* Coat rows in wizard */
|
|
||||||
.coat-row { border: 1px solid #dee2e6; border-radius: .5rem; padding: .75rem; margin-bottom: .5rem; }
|
|
||||||
</style>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
|
|||||||
@@ -71,6 +71,59 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
|
<div class="mobile-card-view">
|
||||||
|
<div class="mobile-card-list">
|
||||||
|
@foreach (var job in overdueJobs)
|
||||||
|
{
|
||||||
|
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", "Jobs", new { id = job.JobId })'">
|
||||||
|
<div class="mobile-card-header">
|
||||||
|
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #dc2626 0%, #991b1b 100%);">
|
||||||
|
<i class="bi bi-exclamation-triangle"></i>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-title">
|
||||||
|
<h6>@job.JobNumber</h6>
|
||||||
|
<small>@job.CustomerName</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-body">
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Status</span>
|
||||||
|
<span class="mobile-card-value"><span class="badge bg-@job.StatusColorClass">@job.StatusDisplayName</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Priority</span>
|
||||||
|
<span class="mobile-card-value"><span class="badge bg-@job.PriorityColorClass">@job.PriorityDisplayName</span></span>
|
||||||
|
</div>
|
||||||
|
@if (job.ScheduledDate.HasValue)
|
||||||
|
{
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Scheduled</span>
|
||||||
|
<span class="mobile-card-value text-danger fw-bold">@job.ScheduledDate.Value.ToString("MMM d, yyyy")</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (job.DueDate.HasValue)
|
||||||
|
{
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Due</span>
|
||||||
|
<span class="mobile-card-value text-danger">@job.DueDate.Value.ToString("MMM d, yyyy")</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-footer">
|
||||||
|
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@job.JobId" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
|
||||||
|
<i class="bi bi-eye me-1"></i>View
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation(); openPriorityModal(@job.JobId, @job.JobPriorityId, '@job.JobNumber')">
|
||||||
|
<i class="bi bi-flag"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation(); openWorkerModal(@job.JobId, '@(job.AssignedUserId ?? "")', '@job.JobNumber')">
|
||||||
|
<i class="bi bi-person"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover mb-0">
|
<table class="table table-hover mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -191,6 +244,74 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
|
<div class="mobile-card-view">
|
||||||
|
<div class="mobile-card-list">
|
||||||
|
@foreach (var job in Model)
|
||||||
|
{
|
||||||
|
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", "Jobs", new { id = job.JobId })'">
|
||||||
|
<div class="mobile-card-header">
|
||||||
|
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);">
|
||||||
|
<i class="bi bi-kanban"></i>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-title">
|
||||||
|
<h6>@job.JobNumber</h6>
|
||||||
|
<small>@job.CustomerName</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-body">
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Status</span>
|
||||||
|
<span class="mobile-card-value"><span class="badge bg-@job.StatusColorClass" id="status-badge-@job.JobId">@job.StatusDisplayName</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Priority</span>
|
||||||
|
<span class="mobile-card-value"><span class="badge bg-@job.PriorityColorClass priority-badge-@job.JobId">@job.PriorityDisplayName</span></span>
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrEmpty(job.AssignedWorkerName))
|
||||||
|
{
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Worker</span>
|
||||||
|
<span class="mobile-card-value"><span class="badge bg-info"><i class="bi bi-person me-1"></i>@job.AssignedWorkerName</span></span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (job.ScheduledDate.HasValue)
|
||||||
|
{
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Scheduled</span>
|
||||||
|
<span class="mobile-card-value">@job.ScheduledDate.Value.ToString("MMM d, yyyy")</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (job.DueDate.HasValue)
|
||||||
|
{
|
||||||
|
var mJobOverdue = job.DueDate.Value.Date < DateTime.Today;
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Due</span>
|
||||||
|
<span class="mobile-card-value @(mJobOverdue ? "text-danger fw-bold" : "")">@job.DueDate.Value.ToString("MMM d, yyyy")</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-footer">
|
||||||
|
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@job.JobId" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
|
||||||
|
<i class="bi bi-eye me-1"></i>View
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation(); openPriorityModal(@job.JobId, @job.JobPriorityId, '@job.JobNumber')" title="Change Priority">
|
||||||
|
<i class="bi bi-flag"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation(); openWorkerModal(@job.JobId, '@(job.AssignedUserId ?? "")', '@job.JobNumber')" title="Assign Worker">
|
||||||
|
<i class="bi bi-person"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (!Model.Any())
|
||||||
|
{
|
||||||
|
<div class="text-center text-muted py-5">
|
||||||
|
<i class="bi bi-calendar-check fs-1 d-block mb-2 opacity-25"></i>
|
||||||
|
No jobs scheduled for @scheduledDate.ToString("MMMM dd, yyyy").
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover mb-0" id="jobsTable">
|
<table class="table table-hover mb-0" id="jobsTable">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -352,6 +473,65 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
<div class="mobile-card-view">
|
||||||
|
<div class="mobile-card-list">
|
||||||
|
@foreach (var item in maintenanceItems)
|
||||||
|
{
|
||||||
|
var mPriorityBg = item.Priority switch
|
||||||
|
{
|
||||||
|
MaintenancePriority.Critical => "danger",
|
||||||
|
MaintenancePriority.High => "warning",
|
||||||
|
MaintenancePriority.Normal => "info",
|
||||||
|
_ => "secondary"
|
||||||
|
};
|
||||||
|
var mStatusBgM = item.Status == MaintenanceStatus.InProgress ? "success" : "primary";
|
||||||
|
var mStatusLbl = item.Status == MaintenanceStatus.InProgress ? "In Progress" : "Scheduled";
|
||||||
|
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", "Maintenance", new { id = item.Id })'">
|
||||||
|
<div class="mobile-card-header">
|
||||||
|
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #f59e0b 0%, #b45309 100%);">
|
||||||
|
<i class="bi bi-tools"></i>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-title">
|
||||||
|
<h6>@(item.Equipment?.EquipmentName ?? "Maintenance")</h6>
|
||||||
|
<small>@item.MaintenanceType</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-body">
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Priority</span>
|
||||||
|
<span class="mobile-card-value"><span class="badge bg-@mPriorityBg">@item.Priority</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Status</span>
|
||||||
|
<span class="mobile-card-value"><span class="badge bg-@mStatusBgM">@mStatusLbl</span></span>
|
||||||
|
</div>
|
||||||
|
@if (item.AssignedUser != null)
|
||||||
|
{
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Worker</span>
|
||||||
|
<span class="mobile-card-value"><span class="badge bg-info text-dark"><i class="bi bi-person me-1"></i>@item.AssignedUser.FullName</span></span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrEmpty(item.Description))
|
||||||
|
{
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Desc.</span>
|
||||||
|
<span class="mobile-card-value text-muted">@item.Description</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-footer">
|
||||||
|
<a asp-controller="Maintenance" asp-action="Details" asp-route-id="@item.Id" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
|
||||||
|
<i class="bi bi-eye me-1"></i>View
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation(); openMaintenanceWorkerModal(@item.Id, '@(item.AssignedUserId ?? "")', '@(item.Equipment?.EquipmentName ?? "Maintenance")')" title="Assign Worker">
|
||||||
|
<i class="bi bi-person"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover mb-0">
|
<table class="table table-hover mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -47,6 +47,68 @@
|
|||||||
|
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
|
<div class="mobile-card-view">
|
||||||
|
<div class="mobile-card-list">
|
||||||
|
@foreach (var je in Model)
|
||||||
|
{
|
||||||
|
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", new { id = je.Id })'">
|
||||||
|
<div class="mobile-card-header">
|
||||||
|
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);">
|
||||||
|
<i class="bi bi-journal-text"></i>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-title">
|
||||||
|
<h6>
|
||||||
|
@je.EntryNumber
|
||||||
|
@if (je.IsReversal)
|
||||||
|
{
|
||||||
|
<span class="badge bg-secondary ms-1">REV</span>
|
||||||
|
}
|
||||||
|
</h6>
|
||||||
|
<small>@je.EntryDate.ToString("MMM d, yyyy")</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-body">
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Status</span>
|
||||||
|
<span class="mobile-card-value">
|
||||||
|
@if (je.Status == JournalEntryStatus.Draft)
|
||||||
|
{
|
||||||
|
<span class="badge bg-warning text-dark">Draft</span>
|
||||||
|
}
|
||||||
|
else if (je.Status == JournalEntryStatus.Posted)
|
||||||
|
{
|
||||||
|
<span class="badge bg-success">Posted</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="badge bg-secondary">Reversed</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(je.Description))
|
||||||
|
{
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Description</span>
|
||||||
|
<span class="mobile-card-value" style="white-space:normal;text-align:right;">@je.Description</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrWhiteSpace(je.Reference))
|
||||||
|
{
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Reference</span>
|
||||||
|
<span class="mobile-card-value">@je.Reference</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-footer">
|
||||||
|
<a asp-action="Details" asp-route-id="@je.Id" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
|
||||||
|
View
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover mb-0">
|
<table class="table table-hover mb-0">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
string activeFilter = ViewBag.ActiveFilter as string ?? "all";
|
string activeFilter = ViewBag.ActiveFilter as string ?? "all";
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="container-fluid px-4">
|
<div>
|
||||||
<div class="d-flex align-items-center justify-content-between mb-4 flex-wrap gap-2">
|
<div class="d-flex align-items-center justify-content-between mb-4 flex-wrap gap-2">
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex align-items-center gap-3">
|
||||||
<i class="bi bi-clipboard-check fs-3 text-primary"></i>
|
<i class="bi bi-clipboard-check fs-3 text-primary"></i>
|
||||||
@@ -53,17 +53,116 @@
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
<div class="mobile-card-view">
|
||||||
|
<div class="mobile-card-list">
|
||||||
|
@foreach (var s in Model)
|
||||||
|
{
|
||||||
|
<div class="mobile-data-card">
|
||||||
|
<div class="mobile-card-header">
|
||||||
|
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);">
|
||||||
|
<i class="bi bi-clipboard-check"></i>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-title">
|
||||||
|
<h6>@s.CustomerFullName</h6>
|
||||||
|
<small>@(s.SubmittedAt?.ToLocalTime().ToString("MM/dd/yy h:mm tt") ?? s.ExpiresAt.AddHours(-2).ToLocalTime().ToString("MM/dd/yy h:mm tt"))</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-body">
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Status</span>
|
||||||
|
<span class="mobile-card-value">
|
||||||
|
@if (s.Status == KioskSessionStatus.Submitted && s.IsConverted)
|
||||||
|
{
|
||||||
|
<span class="badge bg-success">Converted</span>
|
||||||
|
}
|
||||||
|
else if (s.Status == KioskSessionStatus.Submitted)
|
||||||
|
{
|
||||||
|
<span class="badge bg-info text-dark">Submitted</span>
|
||||||
|
}
|
||||||
|
else if (s.Status == KioskSessionStatus.Active && !s.IsExpired)
|
||||||
|
{
|
||||||
|
<span class="badge bg-warning text-dark">In Progress</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="badge bg-secondary">Expired</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Type</span>
|
||||||
|
<span class="mobile-card-value">
|
||||||
|
@if (s.SessionType == KioskSessionType.InPerson)
|
||||||
|
{
|
||||||
|
<span class="badge bg-primary-subtle text-primary"><i class="bi bi-tablet me-1"></i>In-Person</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="badge" style="background:#ede9fe;color:#6d28d9;"><i class="bi bi-envelope me-1"></i>Remote</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrEmpty(s.CustomerPhone))
|
||||||
|
{
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Phone</span>
|
||||||
|
<span class="mobile-card-value"><a href="tel:@s.CustomerPhone">@s.CustomerPhone</a></span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrEmpty(s.CustomerEmail))
|
||||||
|
{
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Email</span>
|
||||||
|
<span class="mobile-card-value" style="white-space:normal;"><a href="mailto:@s.CustomerEmail">@s.CustomerEmail</a></span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (s.LinkedCustomerId.HasValue)
|
||||||
|
{
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Matched</span>
|
||||||
|
<span class="mobile-card-value">
|
||||||
|
<a href="/Customers/Details/@s.LinkedCustomerId" class="text-success">
|
||||||
|
<i class="bi bi-person-check me-1"></i>Customer record
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-footer">
|
||||||
|
@if (s.LinkedJobId.HasValue)
|
||||||
|
{
|
||||||
|
<a href="/Jobs/Details/@s.LinkedJobId" class="btn btn-sm btn-outline-success">
|
||||||
|
<i class="bi bi-briefcase me-1"></i>Job
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
@if (s.LinkedQuoteId.HasValue)
|
||||||
|
{
|
||||||
|
<a href="/Quotes/Details/@s.LinkedQuoteId" class="btn btn-sm btn-outline-info">
|
||||||
|
<i class="bi bi-file-earmark-text me-1"></i>Quote
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
@if (s.LinkedCustomerId.HasValue)
|
||||||
|
{
|
||||||
|
<a href="/Customers/Details/@s.LinkedCustomerId" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-person me-1"></i>Customer
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover mb-0 align-middle">
|
<table class="table table-hover mb-0 align-middle">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Date</th>
|
<th class="d-none d-md-table-cell">Date</th>
|
||||||
<th>Customer</th>
|
<th>Customer</th>
|
||||||
<th>Contact</th>
|
<th class="d-none d-lg-table-cell">Contact</th>
|
||||||
<th>Project</th>
|
<th class="d-none d-lg-table-cell">Project</th>
|
||||||
<th>Type</th>
|
<th class="d-none d-sm-table-cell">Type</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>SMS</th>
|
<th class="d-none d-md-table-cell">SMS</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -71,7 +170,7 @@
|
|||||||
@foreach (var s in Model)
|
@foreach (var s in Model)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td class="text-nowrap text-muted small">
|
<td class="text-nowrap text-muted small d-none d-md-table-cell">
|
||||||
@(s.SubmittedAt?.ToLocalTime().ToString("MM/dd/yy h:mm tt") ?? s.ExpiresAt.AddHours(-2).ToLocalTime().ToString("MM/dd/yy h:mm tt"))
|
@(s.SubmittedAt?.ToLocalTime().ToString("MM/dd/yy h:mm tt") ?? s.ExpiresAt.AddHours(-2).ToLocalTime().ToString("MM/dd/yy h:mm tt"))
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -82,8 +181,12 @@
|
|||||||
<i class="bi bi-person-check me-1"></i>Customer matched
|
<i class="bi bi-person-check me-1"></i>Customer matched
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
@* Show date inline on mobile since the Date column is hidden *@
|
||||||
|
<div class="text-muted small d-md-none">
|
||||||
|
@(s.SubmittedAt?.ToLocalTime().ToString("MM/dd/yy h:mm tt") ?? s.ExpiresAt.AddHours(-2).ToLocalTime().ToString("MM/dd/yy h:mm tt"))
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="small text-muted">
|
<td class="small text-muted d-none d-lg-table-cell">
|
||||||
@if (!string.IsNullOrEmpty(s.CustomerPhone))
|
@if (!string.IsNullOrEmpty(s.CustomerPhone))
|
||||||
{
|
{
|
||||||
<div><i class="bi bi-telephone me-1"></i>@s.CustomerPhone</div>
|
<div><i class="bi bi-telephone me-1"></i>@s.CustomerPhone</div>
|
||||||
@@ -93,11 +196,11 @@
|
|||||||
<div><i class="bi bi-envelope me-1"></i>@s.CustomerEmail</div>
|
<div><i class="bi bi-envelope me-1"></i>@s.CustomerEmail</div>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td style="max-width:280px;">
|
<td class="d-none d-lg-table-cell" style="max-width:280px;">
|
||||||
<span class="text-truncate d-block" style="max-width:260px;"
|
<span class="text-truncate d-block" style="max-width:260px;"
|
||||||
title="@s.JobDescription">@s.JobDescriptionSnippet</span>
|
title="@s.JobDescription">@s.JobDescriptionSnippet</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="d-none d-sm-table-cell">
|
||||||
@if (s.SessionType == KioskSessionType.InPerson)
|
@if (s.SessionType == KioskSessionType.InPerson)
|
||||||
{
|
{
|
||||||
<span class="badge bg-primary-subtle text-primary">
|
<span class="badge bg-primary-subtle text-primary">
|
||||||
@@ -129,7 +232,7 @@
|
|||||||
<span class="badge bg-secondary">Expired</span>
|
<span class="badge bg-secondary">Expired</span>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="d-none d-md-table-cell">
|
||||||
@if (s.SmsOptIn)
|
@if (s.SmsOptIn)
|
||||||
{
|
{
|
||||||
<i class="bi bi-check-circle-fill text-success" title="SMS opt-in"></i>
|
<i class="bi bi-check-circle-fill text-success" title="SMS opt-in"></i>
|
||||||
@@ -143,19 +246,19 @@
|
|||||||
@if (s.LinkedJobId.HasValue)
|
@if (s.LinkedJobId.HasValue)
|
||||||
{
|
{
|
||||||
<a href="/Jobs/Details/@s.LinkedJobId" class="btn btn-sm btn-outline-success me-1">
|
<a href="/Jobs/Details/@s.LinkedJobId" class="btn btn-sm btn-outline-success me-1">
|
||||||
<i class="bi bi-briefcase me-1"></i>View Job
|
<i class="bi bi-briefcase me-1"></i><span class="d-none d-sm-inline">View Job</span><span class="d-sm-none">Job</span>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
@if (s.LinkedQuoteId.HasValue)
|
@if (s.LinkedQuoteId.HasValue)
|
||||||
{
|
{
|
||||||
<a href="/Quotes/Details/@s.LinkedQuoteId" class="btn btn-sm btn-outline-info me-1">
|
<a href="/Quotes/Details/@s.LinkedQuoteId" class="btn btn-sm btn-outline-info me-1">
|
||||||
<i class="bi bi-file-earmark-text me-1"></i>View Quote
|
<i class="bi bi-file-earmark-text me-1"></i><span class="d-none d-sm-inline">View Quote</span><span class="d-sm-none">Quote</span>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
@if (s.LinkedCustomerId.HasValue)
|
@if (s.LinkedCustomerId.HasValue)
|
||||||
{
|
{
|
||||||
<a href="/Customers/Details/@s.LinkedCustomerId" class="btn btn-sm btn-outline-primary">
|
<a href="/Customers/Details/@s.LinkedCustomerId" class="btn btn-sm btn-outline-primary">
|
||||||
<i class="bi bi-person me-1"></i>Customer
|
<i class="bi bi-person me-1"></i><span class="d-none d-sm-inline">Customer</span>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -134,6 +134,67 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
<div class="mobile-card-view">
|
||||||
|
<div class="mobile-card-list">
|
||||||
|
@foreach (var item in Model.Items)
|
||||||
|
{
|
||||||
|
<div class="mobile-data-card">
|
||||||
|
<div class="mobile-card-header">
|
||||||
|
<div class="mobile-card-icon" style="background: @(item.Channel == PowderCoating.Core.Enums.NotificationChannel.Email ? "linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)" : "linear-gradient(135deg, #06b6d4 0%, #0e7490 100%)");">
|
||||||
|
<i class="bi @(item.Channel == PowderCoating.Core.Enums.NotificationChannel.Email ? "bi-envelope" : "bi-phone")"></i>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-title">
|
||||||
|
<h6>@item.RecipientName</h6>
|
||||||
|
<small>@item.Recipient</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-body">
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Type</span>
|
||||||
|
<span class="mobile-card-value">@item.NotificationTypeDisplay</span>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Sent</span>
|
||||||
|
<span class="mobile-card-value">@item.SentAt.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd HH:mm")</span>
|
||||||
|
</div>
|
||||||
|
@if (item.JobId.HasValue)
|
||||||
|
{
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Job</span>
|
||||||
|
<span class="mobile-card-value">@item.JobNumber</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (item.QuoteId.HasValue)
|
||||||
|
{
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Quote</span>
|
||||||
|
<span class="mobile-card-value">@item.QuoteNumber</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Status</span>
|
||||||
|
<span class="mobile-card-value">
|
||||||
|
@{
|
||||||
|
var (mStatusBadge, mStatusIcon) = item.Status switch
|
||||||
|
{
|
||||||
|
PowderCoating.Core.Enums.NotificationStatus.Sent => ("bg-success", "bi-check-circle"),
|
||||||
|
PowderCoating.Core.Enums.NotificationStatus.Failed => ("bg-danger", "bi-x-circle"),
|
||||||
|
_ => ("bg-secondary", "bi-dash-circle")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
<span class="badge @mStatusBadge"><i class="bi @mStatusIcon me-1"></i>@item.StatusDisplay</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-footer">
|
||||||
|
<a asp-action="Details" asp-route-id="@item.Id" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="bi bi-eye me-1"></i>View
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover align-middle mb-0">
|
<table class="table table-hover align-middle mb-0">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
|
|||||||
@@ -44,6 +44,91 @@
|
|||||||
|
|
||||||
<div class="card border-0 shadow-sm">
|
<div class="card border-0 shadow-sm">
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
|
<div class="mobile-card-view">
|
||||||
|
<div class="mobile-card-list">
|
||||||
|
@foreach (var row in Model.Rows)
|
||||||
|
{
|
||||||
|
var oPct = row.TotalSteps == 0 ? 0 : row.StepsCompleted * 100 / row.TotalSteps;
|
||||||
|
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", "Companies", new { id = row.CompanyId })'">
|
||||||
|
<div class="mobile-card-header">
|
||||||
|
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);">
|
||||||
|
<i class="bi bi-building"></i>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-title">
|
||||||
|
<h6>@row.CompanyName</h6>
|
||||||
|
<small>
|
||||||
|
@switch (row.Status)
|
||||||
|
{
|
||||||
|
case OnboardingStatus.Complete:
|
||||||
|
<span class="badge bg-success">Complete</span>
|
||||||
|
break;
|
||||||
|
case OnboardingStatus.InProgress:
|
||||||
|
<span class="badge bg-warning text-dark">In Progress</span>
|
||||||
|
break;
|
||||||
|
case OnboardingStatus.Dismissed:
|
||||||
|
<span class="badge bg-secondary">Dismissed</span>
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
<span class="badge bg-light text-muted border">Not Started</span>
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-body">
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Wizard</span>
|
||||||
|
<span class="mobile-card-value">
|
||||||
|
@if (row.WizardCompleted)
|
||||||
|
{
|
||||||
|
<i class="bi bi-check-circle-fill text-success"></i>
|
||||||
|
<span class="text-success ms-1">Done</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<i class="bi bi-circle text-muted"></i>
|
||||||
|
<span class="text-muted ms-1">Pending</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Milestones</span>
|
||||||
|
<span class="mobile-card-value">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<div class="progress" style="height:5px; width:60px;">
|
||||||
|
<div class="progress-bar @(oPct == 100 ? "bg-success" : "bg-primary")" style="width:@oPct%"></div>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">@row.StepsCompleted/@row.TotalSteps</small>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@{
|
||||||
|
var oFirstActivity = row.FirstJobCreatedAt ?? row.FirstQuoteCreatedAt;
|
||||||
|
}
|
||||||
|
@if (oFirstActivity.HasValue)
|
||||||
|
{
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">First Activity</span>
|
||||||
|
<span class="mobile-card-value text-muted">@oFirstActivity.Value.ToString("MMM d, yyyy")</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-footer">
|
||||||
|
<a asp-controller="Companies" asp-action="Details" asp-route-id="@row.CompanyId" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
|
||||||
|
<i class="bi bi-building me-1"></i>View
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (!Model.Rows.Any())
|
||||||
|
{
|
||||||
|
<div class="text-center text-muted py-5">
|
||||||
|
<i class="bi bi-building fs-1 d-block mb-2 opacity-25"></i>
|
||||||
|
No companies found.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover align-middle mb-0" id="onboardingTable">
|
<table class="table table-hover align-middle mb-0" id="onboardingTable">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
|
|||||||
@@ -85,7 +85,7 @@
|
|||||||
<a tabindex="0" class="help-icon" role="button"
|
<a tabindex="0" class="help-icon" role="button"
|
||||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||||
data-bs-title="Personal Information"
|
data-bs-title="Personal Information"
|
||||||
data-bs-content="First Name, Last Name, and Phone are editable and saved when you click Save Profile. Email is shown here for reference — change it on the Security tab. Department, Position, Role, and Employee Number are set by an administrator and cannot be changed here.">
|
data-bs-content="First Name, Last Name, and Phone are editable and saved when you click Save Profile. Email is shown here for reference â€" change it on the Security tab. Department, Position, Role, and Employee Number are set by an administrator and cannot be changed here.">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -290,7 +290,7 @@
|
|||||||
<a tabindex="0" class="help-icon" role="button"
|
<a tabindex="0" class="help-icon" role="button"
|
||||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||||
data-bs-title="Appearance Settings"
|
data-bs-title="Appearance Settings"
|
||||||
data-bs-content="Theme switches the app between Light and Dark mode. Sidebar Color changes the navigation panel background — click a swatch to preview it instantly. Date Format controls how dates display throughout the app. Timezone is used to localize timestamps. Click Save Appearance to persist your choices.">
|
data-bs-content="Theme switches the app between Light and Dark mode. Sidebar Color changes the navigation panel background â€" click a swatch to preview it instantly. Date Format controls how dates display throughout the app. Timezone is used to localize timestamps. Click Save Appearance to persist your choices.">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -364,39 +364,39 @@
|
|||||||
<label class="form-label fw-semibold">Timezone</label>
|
<label class="form-label fw-semibold">Timezone</label>
|
||||||
<select class="form-select" id="timezoneInput" name="TimeZone" style="max-width:350px;">
|
<select class="form-select" id="timezoneInput" name="TimeZone" style="max-width:350px;">
|
||||||
<optgroup label="United States">
|
<optgroup label="United States">
|
||||||
<option value="America/New_York" selected="@(Model.TimeZone == "America/New_York" ? "selected" : null)">Eastern (ET) — New York</option>
|
<option value="America/New_York" selected="@(Model.TimeZone == "America/New_York" ? "selected" : null)">Eastern (ET) — New York</option>
|
||||||
<option value="America/Chicago" selected="@(Model.TimeZone == "America/Chicago" ? "selected" : null)">Central (CT) — Chicago</option>
|
<option value="America/Chicago" selected="@(Model.TimeZone == "America/Chicago" ? "selected" : null)">Central (CT) — Chicago</option>
|
||||||
<option value="America/Denver" selected="@(Model.TimeZone == "America/Denver" ? "selected" : null)">Mountain (MT) — Denver</option>
|
<option value="America/Denver" selected="@(Model.TimeZone == "America/Denver" ? "selected" : null)">Mountain (MT) — Denver</option>
|
||||||
<option value="America/Phoenix" selected="@(Model.TimeZone == "America/Phoenix" ? "selected" : null)">Mountain no-DST — Phoenix</option>
|
<option value="America/Phoenix" selected="@(Model.TimeZone == "America/Phoenix" ? "selected" : null)">Mountain no-DST — Phoenix</option>
|
||||||
<option value="America/Los_Angeles" selected="@(Model.TimeZone == "America/Los_Angeles" ? "selected" : null)">Pacific (PT) — Los Angeles</option>
|
<option value="America/Los_Angeles" selected="@(Model.TimeZone == "America/Los_Angeles" ? "selected" : null)">Pacific (PT) — Los Angeles</option>
|
||||||
<option value="America/Anchorage" selected="@(Model.TimeZone == "America/Anchorage" ? "selected" : null)">Alaska (AKT) — Anchorage</option>
|
<option value="America/Anchorage" selected="@(Model.TimeZone == "America/Anchorage" ? "selected" : null)">Alaska (AKT) — Anchorage</option>
|
||||||
<option value="Pacific/Honolulu" selected="@(Model.TimeZone == "Pacific/Honolulu" ? "selected" : null)">Hawaii (HT) — Honolulu</option>
|
<option value="Pacific/Honolulu" selected="@(Model.TimeZone == "Pacific/Honolulu" ? "selected" : null)">Hawaii (HT) — Honolulu</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
<optgroup label="Canada">
|
<optgroup label="Canada">
|
||||||
<option value="America/Halifax" selected="@(Model.TimeZone == "America/Halifax" ? "selected" : null)">Atlantic (AT) — Halifax</option>
|
<option value="America/Halifax" selected="@(Model.TimeZone == "America/Halifax" ? "selected" : null)">Atlantic (AT) — Halifax</option>
|
||||||
<option value="America/Toronto" selected="@(Model.TimeZone == "America/Toronto" ? "selected" : null)">Eastern — Toronto</option>
|
<option value="America/Toronto" selected="@(Model.TimeZone == "America/Toronto" ? "selected" : null)">Eastern — Toronto</option>
|
||||||
<option value="America/Winnipeg" selected="@(Model.TimeZone == "America/Winnipeg" ? "selected" : null)">Central — Winnipeg</option>
|
<option value="America/Winnipeg" selected="@(Model.TimeZone == "America/Winnipeg" ? "selected" : null)">Central — Winnipeg</option>
|
||||||
<option value="America/Edmonton" selected="@(Model.TimeZone == "America/Edmonton" ? "selected" : null)">Mountain — Edmonton</option>
|
<option value="America/Edmonton" selected="@(Model.TimeZone == "America/Edmonton" ? "selected" : null)">Mountain — Edmonton</option>
|
||||||
<option value="America/Vancouver" selected="@(Model.TimeZone == "America/Vancouver" ? "selected" : null)">Pacific — Vancouver</option>
|
<option value="America/Vancouver" selected="@(Model.TimeZone == "America/Vancouver" ? "selected" : null)">Pacific — Vancouver</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
<optgroup label="Europe">
|
<optgroup label="Europe">
|
||||||
<option value="Europe/London" selected="@(Model.TimeZone == "Europe/London" ? "selected" : null)">GMT/BST — London</option>
|
<option value="Europe/London" selected="@(Model.TimeZone == "Europe/London" ? "selected" : null)">GMT/BST — London</option>
|
||||||
<option value="Europe/Paris" selected="@(Model.TimeZone == "Europe/Paris" ? "selected" : null)">CET — Paris / Berlin</option>
|
<option value="Europe/Paris" selected="@(Model.TimeZone == "Europe/Paris" ? "selected" : null)">CET — Paris / Berlin</option>
|
||||||
<option value="Europe/Helsinki" selected="@(Model.TimeZone == "Europe/Helsinki" ? "selected" : null)">EET — Helsinki</option>
|
<option value="Europe/Helsinki" selected="@(Model.TimeZone == "Europe/Helsinki" ? "selected" : null)">EET — Helsinki</option>
|
||||||
<option value="Europe/Moscow" selected="@(Model.TimeZone == "Europe/Moscow" ? "selected" : null)">MSK — Moscow</option>
|
<option value="Europe/Moscow" selected="@(Model.TimeZone == "Europe/Moscow" ? "selected" : null)">MSK — Moscow</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
<optgroup label="Asia / Pacific">
|
<optgroup label="Asia / Pacific">
|
||||||
<option value="Asia/Dubai" selected="@(Model.TimeZone == "Asia/Dubai" ? "selected" : null)">GST — Dubai</option>
|
<option value="Asia/Dubai" selected="@(Model.TimeZone == "Asia/Dubai" ? "selected" : null)">GST — Dubai</option>
|
||||||
<option value="Asia/Kolkata" selected="@(Model.TimeZone == "Asia/Kolkata" ? "selected" : null)">IST — India</option>
|
<option value="Asia/Kolkata" selected="@(Model.TimeZone == "Asia/Kolkata" ? "selected" : null)">IST — India</option>
|
||||||
<option value="Asia/Bangkok" selected="@(Model.TimeZone == "Asia/Bangkok" ? "selected" : null)">ICT — Bangkok</option>
|
<option value="Asia/Bangkok" selected="@(Model.TimeZone == "Asia/Bangkok" ? "selected" : null)">ICT — Bangkok</option>
|
||||||
<option value="Asia/Shanghai" selected="@(Model.TimeZone == "Asia/Shanghai" ? "selected" : null)">CST — Beijing / Shanghai</option>
|
<option value="Asia/Shanghai" selected="@(Model.TimeZone == "Asia/Shanghai" ? "selected" : null)">CST — Beijing / Shanghai</option>
|
||||||
<option value="Asia/Tokyo" selected="@(Model.TimeZone == "Asia/Tokyo" ? "selected" : null)">JST — Tokyo</option>
|
<option value="Asia/Tokyo" selected="@(Model.TimeZone == "Asia/Tokyo" ? "selected" : null)">JST — Tokyo</option>
|
||||||
<option value="Australia/Sydney" selected="@(Model.TimeZone == "Australia/Sydney" ? "selected" : null)">AEST — Sydney</option>
|
<option value="Australia/Sydney" selected="@(Model.TimeZone == "Australia/Sydney" ? "selected" : null)">AEST — Sydney</option>
|
||||||
<option value="Pacific/Auckland" selected="@(Model.TimeZone == "Pacific/Auckland" ? "selected" : null)">NZST — Auckland</option>
|
<option value="Pacific/Auckland" selected="@(Model.TimeZone == "Pacific/Auckland" ? "selected" : null)">NZST — Auckland</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
<optgroup label="South America">
|
<optgroup label="South America">
|
||||||
<option value="America/Sao_Paulo" selected="@(Model.TimeZone == "America/Sao_Paulo" ? "selected" : null)">BRT — São Paulo</option>
|
<option value="America/Sao_Paulo" selected="@(Model.TimeZone == "America/Sao_Paulo" ? "selected" : null)">BRT — São Paulo</option>
|
||||||
<option value="America/Buenos_Aires" selected="@(Model.TimeZone == "America/Buenos_Aires" ? "selected" : null)">ART — Buenos Aires</option>
|
<option value="America/Buenos_Aires" selected="@(Model.TimeZone == "America/Buenos_Aires" ? "selected" : null)">ART — Buenos Aires</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
<optgroup label="UTC">
|
<optgroup label="UTC">
|
||||||
<option value="UTC" selected="@(Model.TimeZone == "UTC" ? "selected" : null)">UTC</option>
|
<option value="UTC" selected="@(Model.TimeZone == "UTC" ? "selected" : null)">UTC</option>
|
||||||
@@ -538,7 +538,7 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Theme radio — map light/dark → paper/ink surface system
|
// Theme radio â€" map light/dark → paper/ink surface system
|
||||||
document.querySelectorAll('input[name="theme"]').forEach(radio => {
|
document.querySelectorAll('input[name="theme"]').forEach(radio => {
|
||||||
radio.addEventListener('change', function () {
|
radio.addEventListener('change', function () {
|
||||||
var surface = this.value === 'dark' ? 'ink' : 'paper';
|
var surface = this.value === 'dark' ? 'ink' : 'paper';
|
||||||
|
|||||||
@@ -164,6 +164,56 @@
|
|||||||
<!-- Grid -->
|
<!-- Grid -->
|
||||||
<div class="card border-0 shadow-sm">
|
<div class="card border-0 shadow-sm">
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
|
<div class="mobile-card-view">
|
||||||
|
<div class="mobile-card-list">
|
||||||
|
@foreach (var po in Model.Items)
|
||||||
|
{
|
||||||
|
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", new { id = po.Id })'">
|
||||||
|
<div class="mobile-card-header" style="@(po.IsOverdue ? "background:#fee2e2;" : "")">
|
||||||
|
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);">
|
||||||
|
<i class="bi bi-cart-check"></i>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-title">
|
||||||
|
<h6>@po.PoNumber @(po.IsOverdue ? " — Overdue" : "")</h6>
|
||||||
|
<small>@po.VendorName</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-body">
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Status</span>
|
||||||
|
<span class="mobile-card-value"><span class="badge bg-@StatusBadge(po.Status)">@po.Status</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Order Date</span>
|
||||||
|
<span class="mobile-card-value">@po.OrderDate.ToString("MM/dd/yy")</span>
|
||||||
|
</div>
|
||||||
|
@if (po.ExpectedDeliveryDate.HasValue)
|
||||||
|
{
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Expected</span>
|
||||||
|
<span class="mobile-card-value @(po.IsOverdue ? "text-danger fw-semibold" : "")">
|
||||||
|
@po.ExpectedDeliveryDate.Value.ToString("MM/dd/yy")
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Items</span>
|
||||||
|
<span class="mobile-card-value">@po.ItemCount</span>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Total</span>
|
||||||
|
<span class="mobile-card-value fw-semibold">$@po.TotalAmount.ToString("N2")</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-footer">
|
||||||
|
<a asp-action="Details" asp-route-id="@po.Id" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
|
||||||
|
<i class="bi bi-eye me-1"></i>View
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover mb-0">
|
<table class="table table-hover mb-0">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@model PowderCoating.Application.DTOs.Quote.CreateQuoteDto
|
@model PowderCoating.Application.DTOs.Quote.CreateQuoteDto
|
||||||
@using PowderCoating.Core.Entities
|
@using PowderCoating.Core.Entities
|
||||||
|
|
||||||
@{
|
@{
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
<a tabindex="0" class="help-icon" role="button"
|
<a tabindex="0" class="help-icon" role="button"
|
||||||
data-bs-toggle="popover" data-bs-placement="right"
|
data-bs-toggle="popover" data-bs-placement="right"
|
||||||
data-bs-title="Customer vs Prospect/Walk-In"
|
data-bs-title="Customer vs Prospect/Walk-In"
|
||||||
data-bs-content="Choose <strong>Existing Customer</strong> if this person is already in your system. Choose <strong>New Prospect/Walk-In</strong> if they haven't committed yet — their details stay on the quote. When they approve, you can convert them to a full customer record with one click.<br><br><a href='/Help/Quotes#prospect-conversion' target='_blank'>Learn more →</a>">
|
data-bs-content="Choose <strong>Existing Customer</strong> if this person is already in your system. Choose <strong>New Prospect/Walk-In</strong> if they haven't committed yet — their details stay on the quote. When they approve, you can convert them to a full customer record with one click.<br><br><a href='/Help/Quotes#prospect-conversion' target='_blank'>Learn more →</a>">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
</h5>
|
</h5>
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
<a tabindex="0" class="help-icon" role="button"
|
<a tabindex="0" class="help-icon" role="button"
|
||||||
data-bs-toggle="popover" data-bs-placement="right"
|
data-bs-toggle="popover" data-bs-placement="right"
|
||||||
data-bs-title="Quote Information"
|
data-bs-title="Quote Information"
|
||||||
data-bs-content="Set the quote date, expiration, and any internal notes. The <strong>Expiration Date</strong> is shown to the customer — once it passes the quote is flagged Expired and can no longer be approved without editing. The <strong>Customer PO</strong> field is optional — use it if the customer provides their own purchase order number.<br><br><a href='/Help/Quotes#quote-statuses' target='_blank'>Learn more →</a>">
|
data-bs-content="Set the quote date, expiration, and any internal notes. The <strong>Expiration Date</strong> is shown to the customer — once it passes the quote is flagged Expired and can no longer be approved without editing. The <strong>Customer PO</strong> field is optional — use it if the customer provides their own purchase order number.<br><br><a href='/Help/Quotes#quote-statuses' target='_blank'>Learn more →</a>">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
</h5>
|
</h5>
|
||||||
@@ -253,7 +253,7 @@
|
|||||||
<a tabindex="0" class="help-icon" role="button"
|
<a tabindex="0" class="help-icon" role="button"
|
||||||
data-bs-toggle="popover" data-bs-placement="right"
|
data-bs-toggle="popover" data-bs-placement="right"
|
||||||
data-bs-title="Quote Item Types"
|
data-bs-title="Quote Item Types"
|
||||||
data-bs-content="<strong>Calculated</strong> — you enter surface area (sq ft) and the system prices it using your rates for materials, labour, and overhead.<br><strong>Custom Work</strong> — you enter a description and a manual price. Use this for flat-rate jobs or work that doesn't fit the formula.<br><strong>AI Photo</strong> — upload photos and let the AI estimate surface area and complexity for you.<br><br><a href='/Help/Quotes#quote-items' target='_blank'>Learn more →</a>">
|
data-bs-content="<strong>Calculated</strong> — you enter surface area (sq ft) and the system prices it using your rates for materials, labour, and overhead.<br><strong>Custom Work</strong> — you enter a description and a manual price. Use this for flat-rate jobs or work that doesn't fit the formula.<br><strong>AI Photo</strong> — upload photos and let the AI estimate surface area and complexity for you.<br><br><a href='/Help/Quotes#quote-items' target='_blank'>Learn more →</a>">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
</h5>
|
</h5>
|
||||||
@@ -329,7 +329,7 @@
|
|||||||
<a tabindex="0" class="help-icon text-white" role="button"
|
<a tabindex="0" class="help-icon text-white" role="button"
|
||||||
data-bs-toggle="popover" data-bs-placement="left"
|
data-bs-toggle="popover" data-bs-placement="left"
|
||||||
data-bs-title="Pricing Summary"
|
data-bs-title="Pricing Summary"
|
||||||
data-bs-content="The total is built up from materials, labour, equipment time, overhead, and profit margin — all based on the rates in Settings. A <strong>Tier Discount</strong> appears automatically if the customer has a pricing tier assigned. A <strong>Rush Fee</strong> is added when Rush Job is checked.<br><br><a href='/Help/Quotes#pricing-breakdown' target='_blank'>Learn more →</a>">
|
data-bs-content="The total is built up from materials, labour, equipment time, overhead, and profit margin — all based on the rates in Settings. A <strong>Tier Discount</strong> appears automatically if the customer has a pricing tier assigned. A <strong>Rush Fee</strong> is added when Rush Job is checked.<br><br><a href='/Help/Quotes#pricing-breakdown' target='_blank'>Learn more →</a>">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
</h5>
|
</h5>
|
||||||
@@ -422,99 +422,8 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Surface Area Calculator Modal -->
|
@await Html.PartialAsync("_SqFtCalculatorModal")
|
||||||
<div class="modal fade" id="sqFtCalculatorModal" tabindex="-1">
|
@await Html.PartialAsync("_ItemWizardModal")
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title"><i class="bi bi-calculator me-2"></i>Surface Area Calculator <small class="text-muted">(per item)</small></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">Shape</label>
|
|
||||||
<select id="calcShape" class="form-select" onchange="toggleShapeInputs()">
|
|
||||||
<option value="rectangle">Rectangle / Square</option>
|
|
||||||
<option value="cylinder">Cylinder (Tube)</option>
|
|
||||||
<option value="circle">Circle (Flat)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div id="rectangleInputs">
|
|
||||||
<div class="row g-2">
|
|
||||||
<div class="col-6"><label class="form-label">Length (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
|
||||||
<input type="number" id="rectLength" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
|
||||||
<div class="col-6"><label class="form-label">Width (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
|
||||||
<input type="number" id="rectWidth" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
|
||||||
</div>
|
|
||||||
<small class="text-muted">Formula: L × W ÷ @(ViewBag.UseMetric == true ? "10,000" : "144")</small>
|
|
||||||
</div>
|
|
||||||
<div id="cylinderInputs" style="display:none">
|
|
||||||
<div class="row g-2">
|
|
||||||
<div class="col-6"><label class="form-label">Diameter (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
|
||||||
<input type="number" id="cylDiameter" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
|
||||||
<div class="col-6"><label class="form-label">Height (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
|
||||||
<input type="number" id="cylHeight" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="circleInputs" style="display:none">
|
|
||||||
<label class="form-label">Diameter (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
|
||||||
<input type="number" id="circDiameter" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()">
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<div class="alert alert-info alert-permanent mb-0"><strong>Result:</strong> <span id="calcResult">0.00</span> @ViewBag.AreaUnit</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<button type="button" class="btn btn-primary" onclick="useSqFtResult()">
|
|
||||||
<i class="bi bi-check-circle me-1"></i>Use This Value
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ========================= ITEM WIZARD MODAL ========================= -->
|
|
||||||
<div class="modal fade" id="itemWizardModal" tabindex="-1" data-bs-backdrop="static">
|
|
||||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<div class="d-flex flex-column">
|
|
||||||
<h5 class="modal-title mb-0" id="wizardTitle">Add Item</h5>
|
|
||||||
<div class="text-muted small mb-1" id="wizardStepTitle">Choose Item Type</div>
|
|
||||||
<!-- Step progress -->
|
|
||||||
<div class="d-flex align-items-center gap-2" id="wizardStepIndicator">
|
|
||||||
<span class="wizard-step-dot active" data-step="1" title="Item Type"></span>
|
|
||||||
<div class="wizard-step-line"></div>
|
|
||||||
<span class="wizard-step-dot" data-step="2" title="Item Details"></span>
|
|
||||||
<div class="wizard-step-line" id="step2Line"></div>
|
|
||||||
<span class="wizard-step-dot" data-step="3" title="Coating Layers" id="step3Dot"></span>
|
|
||||||
<div class="wizard-step-line" id="step3Line"></div>
|
|
||||||
<span class="wizard-step-dot" data-step="4" title="Prep Services" id="step4Dot"></span>
|
|
||||||
<span class="text-muted small ms-2" id="wizardStepLabel">Step 1 of 4</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn-close ms-auto" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body" id="wizardBody" style="min-height: 300px;">
|
|
||||||
<!-- Content injected by JS -->
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer justify-content-between">
|
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<button type="button" class="btn btn-outline-secondary d-none" id="btnWizardBack" onclick="wizardBack()">
|
|
||||||
<i class="bi bi-arrow-left me-1"></i>Back
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-primary" id="btnWizardNext" onclick="wizardNext()">
|
|
||||||
Next <i class="bi bi-arrow-right ms-1"></i>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-success d-none" id="btnWizardSave" onclick="wizardSave()">
|
|
||||||
<i class="bi bi-check-lg me-1"></i>Add Item
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Embedded data for JS -->
|
<!-- Embedded data for JS -->
|
||||||
@if (ViewBag.InventoryCoatings != null)
|
@if (ViewBag.InventoryCoatings != null)
|
||||||
@@ -633,47 +542,8 @@
|
|||||||
}
|
}
|
||||||
.quote-mode-opt span:hover { color: var(--bs-body-color); }
|
.quote-mode-opt span:hover { color: var(--bs-body-color); }
|
||||||
.quote-mode-opt input:checked + span:hover { color: #fff; }
|
.quote-mode-opt input:checked + span:hover { color: #fff; }
|
||||||
/* Wizard step indicator */
|
|
||||||
.wizard-step-dot {
|
|
||||||
width: 22px; height: 22px; border-radius: 50%;
|
|
||||||
background: #dee2e6; display: inline-block; cursor: default;
|
|
||||||
border: 2px solid #dee2e6; transition: all .2s;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.wizard-step-dot.active { background: #0d6efd; border-color: #0d6efd; }
|
|
||||||
.wizard-step-dot.done { background: #198754; border-color: #198754; }
|
|
||||||
.wizard-step-dot.skip { background: #adb5bd; border-color: #adb5bd; }
|
|
||||||
.wizard-step-line { flex: 1; height: 2px; background: #dee2e6; min-width: 30px; }
|
|
||||||
/* Item type picker cards */
|
|
||||||
.item-type-card {
|
|
||||||
border: 2px solid #dee2e6; border-radius: .75rem; padding: 1.25rem 1rem;
|
|
||||||
cursor: pointer; transition: all .15s; text-align: center;
|
|
||||||
background: #fff; user-select: none;
|
|
||||||
}
|
|
||||||
.item-type-card:hover { border-color: #86b7fe; background: #f0f6ff; }
|
|
||||||
.item-type-card.selected { border-color: #0d6efd; background: #eef3ff; }
|
|
||||||
.item-type-card .item-type-icon { font-size: 2rem; margin-bottom: .5rem; }
|
|
||||||
[data-bs-theme="dark"] .item-type-card { background: var(--bs-tertiary-bg); border-color: var(--bs-border-color); color: var(--bs-body-color); }
|
|
||||||
[data-bs-theme="dark"] .item-type-card:hover { border-color: #86b7fe; background: var(--bs-secondary-bg); }
|
|
||||||
[data-bs-theme="dark"] .item-type-card.selected { border-color: #0d6efd; background: #1a2a4a; }
|
|
||||||
/* Catalog listbox (replaces native <select> for cross-platform filter support) */
|
|
||||||
.catalog-list-item { cursor: pointer; border-bottom: 1px solid var(--bs-border-color); font-size: .9rem; transition: background .1s; }
|
|
||||||
.catalog-list-item:last-child { border-bottom: none; }
|
|
||||||
.catalog-list-item:hover { background: var(--bs-tertiary-bg); }
|
|
||||||
.catalog-list-item.selected { background: #eef3ff; color: #0d6efd; font-weight: 600; }
|
|
||||||
[data-bs-theme="dark"] .catalog-list-item.selected { background: #1a2a4a; color: #86b7fe; }
|
|
||||||
/* Summary cards */
|
|
||||||
.quote-item-card {
|
|
||||||
border: 1px solid #dee2e6; border-radius: .5rem;
|
|
||||||
padding: .75rem 1rem; margin-bottom: .5rem;
|
|
||||||
background: #fafafa;
|
|
||||||
}
|
|
||||||
.quote-item-card .item-badge { font-size: .7rem; }
|
|
||||||
/* Coat rows in wizard */
|
|
||||||
.coat-row { border: 1px solid #dee2e6; border-radius: .5rem; padding: .75rem; margin-bottom: .5rem; }
|
|
||||||
[data-bs-theme="dark"] .quote-item-card { background: var(--bs-tertiary-bg); border-color: var(--bs-border-color); color: var(--bs-body-color); }
|
|
||||||
[data-bs-theme="dark"] .coat-row { background: var(--bs-tertiary-bg); border-color: var(--bs-border-color); }
|
|
||||||
</style>
|
</style>
|
||||||
|
<link rel="stylesheet" href="~/css/item-wizard.css">
|
||||||
}
|
}
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
@@ -681,7 +551,7 @@
|
|||||||
<script src="~/lib/tom-select/js/tom-select.complete.min.js"></script>
|
<script src="~/lib/tom-select/js/tom-select.complete.min.js"></script>
|
||||||
<script src="~/js/item-wizard.js?v=@DateTime.Now.Ticks"></script>
|
<script src="~/js/item-wizard.js?v=@DateTime.Now.Ticks"></script>
|
||||||
<script>
|
<script>
|
||||||
// ── Quick / Full quote mode toggle ──────────────────────────────────
|
// ── Quick / Full quote mode toggle ──────────────────────────────────
|
||||||
(function () {
|
(function () {
|
||||||
const STORAGE_KEY = 'pcl_quote_mode';
|
const STORAGE_KEY = 'pcl_quote_mode';
|
||||||
const form = document.getElementById('quoteForm');
|
const form = document.getElementById('quoteForm');
|
||||||
@@ -734,6 +604,8 @@
|
|||||||
if (taxField) {
|
if (taxField) {
|
||||||
taxField.value = exemptIds.has(customerId) ? 0 : (meta.companyTaxPercent ?? meta.taxPercent);
|
taxField.value = exemptIds.has(customerId) ? 0 : (meta.companyTaxPercent ?? meta.taxPercent);
|
||||||
}
|
}
|
||||||
|
// Recalculate the live preview so it reflects the updated tax rate immediately
|
||||||
|
if (typeof scheduleAutoPricing === 'function') scheduleAutoPricing();
|
||||||
|
|
||||||
const noEmail = customerId > 0 && noEmailIds.has(customerId);
|
const noEmail = customerId > 0 && noEmailIds.has(customerId);
|
||||||
const emailSection = document.getElementById('emailNotifySection');
|
const emailSection = document.getElementById('emailNotifySection');
|
||||||
@@ -803,52 +675,6 @@
|
|||||||
document.getElementById('hideDiscountSection').style.display = show ? 'block' : 'none';
|
document.getElementById('hideDiscountSection').style.display = show ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Surface area calculator
|
|
||||||
let _sqFtTargetInput = null;
|
|
||||||
function openSqFtCalculator(inputId) {
|
|
||||||
_sqFtTargetInput = inputId;
|
|
||||||
document.getElementById('rectLength').value = 0;
|
|
||||||
document.getElementById('rectWidth').value = 0;
|
|
||||||
document.getElementById('calcResult').textContent = '0.00';
|
|
||||||
new bootstrap.Modal(document.getElementById('sqFtCalculatorModal')).show();
|
|
||||||
}
|
|
||||||
function toggleShapeInputs() {
|
|
||||||
const shape = document.getElementById('calcShape').value;
|
|
||||||
document.getElementById('rectangleInputs').style.display = shape === 'rectangle' ? 'block' : 'none';
|
|
||||||
document.getElementById('cylinderInputs').style.display = shape === 'cylinder' ? 'block' : 'none';
|
|
||||||
document.getElementById('circleInputs').style.display = shape === 'circle' ? 'block' : 'none';
|
|
||||||
calculateSqFt();
|
|
||||||
}
|
|
||||||
function calculateSqFt() {
|
|
||||||
const useMetric = @Json.Serialize((bool)(ViewBag.UseMetric ?? false));
|
|
||||||
const divisor = useMetric ? 10000 : 144;
|
|
||||||
const shape = document.getElementById('calcShape').value;
|
|
||||||
let result = 0;
|
|
||||||
if (shape === 'rectangle') {
|
|
||||||
const l = parseFloat(document.getElementById('rectLength').value) || 0;
|
|
||||||
const w = parseFloat(document.getElementById('rectWidth').value) || 0;
|
|
||||||
result = (l * w) / divisor;
|
|
||||||
} else if (shape === 'cylinder') {
|
|
||||||
const d = parseFloat(document.getElementById('cylDiameter').value) || 0;
|
|
||||||
const h = parseFloat(document.getElementById('cylHeight').value) || 0;
|
|
||||||
const r = d / 2;
|
|
||||||
result = (2 * Math.PI * r * r + 2 * Math.PI * r * h) / divisor;
|
|
||||||
} else {
|
|
||||||
const d = parseFloat(document.getElementById('circDiameter').value) || 0;
|
|
||||||
const r = d / 2;
|
|
||||||
result = (Math.PI * r * r) / divisor;
|
|
||||||
}
|
|
||||||
document.getElementById('calcResult').textContent = result.toFixed(4);
|
|
||||||
}
|
|
||||||
function useSqFtResult() {
|
|
||||||
const val = document.getElementById('calcResult').textContent;
|
|
||||||
if (_sqFtTargetInput) {
|
|
||||||
const el = document.getElementById(_sqFtTargetInput) || document.querySelector(`[name="${_sqFtTargetInput}"]`);
|
|
||||||
if (el) { el.value = parseFloat(val).toFixed(2); el.dispatchEvent(new Event('change')); }
|
|
||||||
}
|
|
||||||
bootstrap.Modal.getInstance(document.getElementById('sqFtCalculatorModal'))?.hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Form submit guard
|
// Form submit guard
|
||||||
document.getElementById('quoteForm').addEventListener('submit', function(e) {
|
document.getElementById('quoteForm').addEventListener('submit', function(e) {
|
||||||
if (typeof quoteItems === 'undefined' || quoteItems.length === 0) {
|
if (typeof quoteItems === 'undefined' || quoteItems.length === 0) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@model PowderCoating.Application.DTOs.Quote.UpdateQuoteDto
|
@model PowderCoating.Application.DTOs.Quote.UpdateQuoteDto
|
||||||
@using PowderCoating.Core.Entities
|
@using PowderCoating.Core.Entities
|
||||||
|
|
||||||
@{
|
@{
|
||||||
@@ -109,7 +109,7 @@
|
|||||||
<a tabindex="0" class="help-icon" role="button"
|
<a tabindex="0" class="help-icon" role="button"
|
||||||
data-bs-toggle="popover" data-bs-placement="right"
|
data-bs-toggle="popover" data-bs-placement="right"
|
||||||
data-bs-title="Quote Information"
|
data-bs-title="Quote Information"
|
||||||
data-bs-content="Set the quote date, expiration, and any internal notes. The <strong>Expiration Date</strong> is shown to the customer — once it passes the quote is flagged Expired and can no longer be approved without editing. The <strong>Customer PO</strong> field is optional — use it if the customer provides their own purchase order number.<br><br><a href='/Help/Quotes#quote-statuses' target='_blank'>Learn more →</a>">
|
data-bs-content="Set the quote date, expiration, and any internal notes. The <strong>Expiration Date</strong> is shown to the customer — once it passes the quote is flagged Expired and can no longer be approved without editing. The <strong>Customer PO</strong> field is optional — use it if the customer provides their own purchase order number.<br><br><a href='/Help/Quotes#quote-statuses' target='_blank'>Learn more →</a>">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
</h5>
|
</h5>
|
||||||
@@ -216,7 +216,7 @@
|
|||||||
<a tabindex="0" class="help-icon" role="button"
|
<a tabindex="0" class="help-icon" role="button"
|
||||||
data-bs-toggle="popover" data-bs-placement="right"
|
data-bs-toggle="popover" data-bs-placement="right"
|
||||||
data-bs-title="Quote Item Types"
|
data-bs-title="Quote Item Types"
|
||||||
data-bs-content="<strong>Calculated</strong> — you enter surface area (sq ft) and the system prices it using your rates for materials, labour, and overhead.<br><strong>Custom Work</strong> — you enter a description and a manual price. Use this for flat-rate jobs or work that doesn't fit the formula.<br><strong>AI Photo</strong> — upload photos and let the AI estimate surface area and complexity for you.<br><br><a href='/Help/Quotes#quote-items' target='_blank'>Learn more →</a>">
|
data-bs-content="<strong>Calculated</strong> — you enter surface area (sq ft) and the system prices it using your rates for materials, labour, and overhead.<br><strong>Custom Work</strong> — you enter a description and a manual price. Use this for flat-rate jobs or work that doesn't fit the formula.<br><strong>AI Photo</strong> — upload photos and let the AI estimate surface area and complexity for you.<br><br><a href='/Help/Quotes#quote-items' target='_blank'>Learn more →</a>">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
</h5>
|
</h5>
|
||||||
@@ -292,7 +292,7 @@
|
|||||||
<a tabindex="0" class="help-icon text-white" role="button"
|
<a tabindex="0" class="help-icon text-white" role="button"
|
||||||
data-bs-toggle="popover" data-bs-placement="left"
|
data-bs-toggle="popover" data-bs-placement="left"
|
||||||
data-bs-title="Pricing Summary"
|
data-bs-title="Pricing Summary"
|
||||||
data-bs-content="The total is built up from materials, labour, equipment time, overhead, and profit margin — all based on the rates in Settings. A <strong>Tier Discount</strong> appears automatically if the customer has a pricing tier assigned. A <strong>Rush Fee</strong> is added when Rush Job is checked.<br><br><a href='/Help/Quotes#pricing-breakdown' target='_blank'>Learn more →</a>">
|
data-bs-content="The total is built up from materials, labour, equipment time, overhead, and profit margin — all based on the rates in Settings. A <strong>Tier Discount</strong> appears automatically if the customer has a pricing tier assigned. A <strong>Rush Fee</strong> is added when Rush Job is checked.<br><br><a href='/Help/Quotes#pricing-breakdown' target='_blank'>Learn more →</a>">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
</h5>
|
</h5>
|
||||||
@@ -459,99 +459,8 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Surface Area Calculator Modal -->
|
@await Html.PartialAsync("_SqFtCalculatorModal")
|
||||||
<div class="modal fade" id="sqFtCalculatorModal" tabindex="-1">
|
@await Html.PartialAsync("_ItemWizardModal")
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title"><i class="bi bi-calculator me-2"></i>Surface Area Calculator <small class="text-muted">(per item)</small></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">Shape</label>
|
|
||||||
<select id="calcShape" class="form-select" onchange="toggleShapeInputs()">
|
|
||||||
<option value="rectangle">Rectangle / Square</option>
|
|
||||||
<option value="cylinder">Cylinder (Tube)</option>
|
|
||||||
<option value="circle">Circle (Flat)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div id="rectangleInputs">
|
|
||||||
<div class="row g-2">
|
|
||||||
<div class="col-6"><label class="form-label">Length (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
|
||||||
<input type="number" id="rectLength" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
|
||||||
<div class="col-6"><label class="form-label">Width (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
|
||||||
<input type="number" id="rectWidth" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
|
||||||
</div>
|
|
||||||
<small class="text-muted">Formula: L × W ÷ @(ViewBag.UseMetric == true ? "10,000" : "144")</small>
|
|
||||||
</div>
|
|
||||||
<div id="cylinderInputs" style="display:none">
|
|
||||||
<div class="row g-2">
|
|
||||||
<div class="col-6"><label class="form-label">Diameter (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
|
||||||
<input type="number" id="cylDiameter" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
|
||||||
<div class="col-6"><label class="form-label">Height (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
|
||||||
<input type="number" id="cylHeight" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="circleInputs" style="display:none">
|
|
||||||
<label class="form-label">Diameter (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
|
||||||
<input type="number" id="circDiameter" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()">
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<div class="alert alert-info alert-permanent mb-0"><strong>Result:</strong> <span id="calcResult">0.00</span> @ViewBag.AreaUnit</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<button type="button" class="btn btn-primary" onclick="useSqFtResult()">
|
|
||||||
<i class="bi bi-check-circle me-1"></i>Use This Value
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ========================= ITEM WIZARD MODAL ========================= -->
|
|
||||||
<div class="modal fade" id="itemWizardModal" tabindex="-1" data-bs-backdrop="static">
|
|
||||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<div class="d-flex flex-column">
|
|
||||||
<h5 class="modal-title mb-0" id="wizardTitle">Add Item</h5>
|
|
||||||
<div class="text-muted small mb-1" id="wizardStepTitle">Choose Item Type</div>
|
|
||||||
<!-- Step progress -->
|
|
||||||
<div class="d-flex align-items-center gap-2" id="wizardStepIndicator">
|
|
||||||
<span class="wizard-step-dot active" data-step="1" title="Item Type"></span>
|
|
||||||
<div class="wizard-step-line"></div>
|
|
||||||
<span class="wizard-step-dot" data-step="2" title="Item Details"></span>
|
|
||||||
<div class="wizard-step-line" id="step2Line"></div>
|
|
||||||
<span class="wizard-step-dot" data-step="3" title="Coating Layers" id="step3Dot"></span>
|
|
||||||
<div class="wizard-step-line" id="step3Line"></div>
|
|
||||||
<span class="wizard-step-dot" data-step="4" title="Prep Services" id="step4Dot"></span>
|
|
||||||
<span class="text-muted small ms-2" id="wizardStepLabel">Step 1 of 4</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn-close ms-auto" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body" id="wizardBody" style="min-height: 300px;">
|
|
||||||
<!-- Content injected by JS -->
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer justify-content-between">
|
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<button type="button" class="btn btn-outline-secondary d-none" id="btnWizardBack" onclick="wizardBack()">
|
|
||||||
<i class="bi bi-arrow-left me-1"></i>Back
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-primary" id="btnWizardNext" onclick="wizardNext()">
|
|
||||||
Next <i class="bi bi-arrow-right ms-1"></i>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-success d-none" id="btnWizardSave" onclick="wizardSave()">
|
|
||||||
<i class="bi bi-check-lg me-1"></i>Add Item
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Embedded data for JS -->
|
<!-- Embedded data for JS -->
|
||||||
@if (ViewBag.InventoryCoatings != null)
|
@if (ViewBag.InventoryCoatings != null)
|
||||||
@@ -647,43 +556,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
@section Styles {
|
@section Styles {
|
||||||
<style>
|
<link rel="stylesheet" href="~/css/item-wizard.css">
|
||||||
.wizard-step-dot {
|
|
||||||
width: 22px; height: 22px; border-radius: 50%;
|
|
||||||
background: #dee2e6; display: inline-block; cursor: default;
|
|
||||||
border: 2px solid #dee2e6; transition: all .2s;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.wizard-step-dot.active { background: #0d6efd; border-color: #0d6efd; }
|
|
||||||
.wizard-step-dot.done { background: #198754; border-color: #198754; }
|
|
||||||
.wizard-step-dot.skip { background: #adb5bd; border-color: #adb5bd; }
|
|
||||||
.wizard-step-line { flex: 1; height: 2px; background: #dee2e6; min-width: 30px; }
|
|
||||||
.item-type-card {
|
|
||||||
border: 2px solid #dee2e6; border-radius: .75rem; padding: 1.25rem 1rem;
|
|
||||||
cursor: pointer; transition: all .15s; text-align: center;
|
|
||||||
background: #fff; user-select: none;
|
|
||||||
}
|
|
||||||
.item-type-card:hover { border-color: #86b7fe; background: #f0f6ff; }
|
|
||||||
.item-type-card.selected { border-color: #0d6efd; background: #eef3ff; }
|
|
||||||
.item-type-card .item-type-icon { font-size: 2rem; margin-bottom: .5rem; }
|
|
||||||
[data-bs-theme="dark"] .item-type-card { background: var(--bs-tertiary-bg); border-color: var(--bs-border-color); color: var(--bs-body-color); }
|
|
||||||
[data-bs-theme="dark"] .item-type-card:hover { border-color: #86b7fe; background: var(--bs-secondary-bg); }
|
|
||||||
[data-bs-theme="dark"] .item-type-card.selected { border-color: #0d6efd; background: #1a2a4a; }
|
|
||||||
.catalog-list-item { cursor: pointer; border-bottom: 1px solid var(--bs-border-color); font-size: .9rem; transition: background .1s; }
|
|
||||||
.catalog-list-item:last-child { border-bottom: none; }
|
|
||||||
.catalog-list-item:hover { background: var(--bs-tertiary-bg); }
|
|
||||||
.catalog-list-item.selected { background: #eef3ff; color: #0d6efd; font-weight: 600; }
|
|
||||||
[data-bs-theme="dark"] .catalog-list-item.selected { background: #1a2a4a; color: #86b7fe; }
|
|
||||||
.quote-item-card {
|
|
||||||
border: 1px solid #dee2e6; border-radius: .5rem;
|
|
||||||
padding: .75rem 1rem; margin-bottom: .5rem;
|
|
||||||
background: #fafafa;
|
|
||||||
}
|
|
||||||
.quote-item-card .item-badge { font-size: .7rem; }
|
|
||||||
.coat-row { border: 1px solid #dee2e6; border-radius: .5rem; padding: .75rem; margin-bottom: .5rem; }
|
|
||||||
[data-bs-theme="dark"] .quote-item-card { background: var(--bs-tertiary-bg); border-color: var(--bs-border-color); color: var(--bs-body-color); }
|
|
||||||
[data-bs-theme="dark"] .coat-row { background: var(--bs-tertiary-bg); border-color: var(--bs-border-color); }
|
|
||||||
</style>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
@@ -758,51 +631,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Surface area calculator
|
// Surface area calculator
|
||||||
let _sqFtTargetInput = null;
|
|
||||||
function openSqFtCalculator(inputId) {
|
|
||||||
_sqFtTargetInput = inputId;
|
|
||||||
document.getElementById('rectLength').value = 0;
|
|
||||||
document.getElementById('rectWidth').value = 0;
|
|
||||||
document.getElementById('calcResult').textContent = '0.00';
|
|
||||||
new bootstrap.Modal(document.getElementById('sqFtCalculatorModal')).show();
|
|
||||||
}
|
|
||||||
function toggleShapeInputs() {
|
|
||||||
const shape = document.getElementById('calcShape').value;
|
|
||||||
document.getElementById('rectangleInputs').style.display = shape === 'rectangle' ? 'block' : 'none';
|
|
||||||
document.getElementById('cylinderInputs').style.display = shape === 'cylinder' ? 'block' : 'none';
|
|
||||||
document.getElementById('circleInputs').style.display = shape === 'circle' ? 'block' : 'none';
|
|
||||||
calculateSqFt();
|
|
||||||
}
|
|
||||||
function calculateSqFt() {
|
|
||||||
const useMetric = @Json.Serialize((bool)(ViewBag.UseMetric ?? false));
|
|
||||||
const divisor = useMetric ? 10000 : 144;
|
|
||||||
const shape = document.getElementById('calcShape').value;
|
|
||||||
let result = 0;
|
|
||||||
if (shape === 'rectangle') {
|
|
||||||
const l = parseFloat(document.getElementById('rectLength').value) || 0;
|
|
||||||
const w = parseFloat(document.getElementById('rectWidth').value) || 0;
|
|
||||||
result = (l * w) / divisor;
|
|
||||||
} else if (shape === 'cylinder') {
|
|
||||||
const d = parseFloat(document.getElementById('cylDiameter').value) || 0;
|
|
||||||
const h = parseFloat(document.getElementById('cylHeight').value) || 0;
|
|
||||||
const r = d / 2;
|
|
||||||
result = (2 * Math.PI * r * r + 2 * Math.PI * r * h) / divisor;
|
|
||||||
} else {
|
|
||||||
const d = parseFloat(document.getElementById('circDiameter').value) || 0;
|
|
||||||
const r = d / 2;
|
|
||||||
result = (Math.PI * r * r) / divisor;
|
|
||||||
}
|
|
||||||
document.getElementById('calcResult').textContent = result.toFixed(4);
|
|
||||||
}
|
|
||||||
function useSqFtResult() {
|
|
||||||
const val = document.getElementById('calcResult').textContent;
|
|
||||||
if (_sqFtTargetInput) {
|
|
||||||
const el = document.getElementById(_sqFtTargetInput) || document.querySelector(`[name="${_sqFtTargetInput}"]`);
|
|
||||||
if (el) { el.value = parseFloat(val).toFixed(2); el.dispatchEvent(new Event('change')); }
|
|
||||||
}
|
|
||||||
bootstrap.Modal.getInstance(document.getElementById('sqFtCalculatorModal'))?.hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Form submit guard
|
// Form submit guard
|
||||||
document.getElementById('quoteForm').addEventListener('submit', function(e) {
|
document.getElementById('quoteForm').addEventListener('submit', function(e) {
|
||||||
if (typeof quoteItems === 'undefined' || quoteItems.length === 0) {
|
if (typeof quoteItems === 'undefined' || quoteItems.length === 0) {
|
||||||
|
|||||||
@@ -38,6 +38,96 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
<div class="mobile-card-view">
|
||||||
|
<div class="mobile-card-list">
|
||||||
|
@foreach (var t in Model)
|
||||||
|
{
|
||||||
|
var isOverdueRT = t.IsActive && t.NextFireDate.Date < DateTime.Today;
|
||||||
|
<div class="mobile-data-card">
|
||||||
|
<div class="mobile-card-header">
|
||||||
|
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);">
|
||||||
|
<i class="bi bi-arrow-repeat"></i>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-title">
|
||||||
|
<h6>@t.Name</h6>
|
||||||
|
<small>
|
||||||
|
@if (t.TemplateType == RecurringTemplateType.Bill)
|
||||||
|
{
|
||||||
|
<span>Bill</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>Expense</span>
|
||||||
|
}
|
||||||
|
—
|
||||||
|
@(t.IntervalCount == 1 ? t.Frequency.ToString() : $"Every {t.IntervalCount} × {t.Frequency}")
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-body">
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Status</span>
|
||||||
|
<span class="mobile-card-value">
|
||||||
|
@if (t.IsActive)
|
||||||
|
{
|
||||||
|
<span class="badge bg-success"><i class="bi bi-play-fill me-1"></i>Active</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="badge bg-secondary"><i class="bi bi-pause-fill me-1"></i>Paused</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@if (t.IsActive)
|
||||||
|
{
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Next Fire</span>
|
||||||
|
<span class="mobile-card-value @(isOverdueRT ? "text-danger fw-semibold" : "")">
|
||||||
|
@t.NextFireDate.ToString("MM/dd/yyyy")
|
||||||
|
@if (isOverdueRT) { <i class="bi bi-exclamation-circle ms-1"></i> }
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Occurrences</span>
|
||||||
|
<span class="mobile-card-value">
|
||||||
|
@t.OccurrenceCount
|
||||||
|
@if (t.MaxOccurrences.HasValue) { <span class="text-muted"> / @t.MaxOccurrences</span> }
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(t.LastError))
|
||||||
|
{
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Error</span>
|
||||||
|
<span class="mobile-card-value text-danger small">@t.LastError</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-footer">
|
||||||
|
<a asp-action="Edit" asp-route-id="@t.Id" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</a>
|
||||||
|
<form asp-action="ToggleActive" asp-route-id="@t.Id" method="post" style="display:inline;">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<button type="submit" class="btn btn-sm @(t.IsActive ? "btn-outline-warning" : "btn-outline-success")">
|
||||||
|
<i class="bi @(t.IsActive ? "bi-pause" : "bi-play")"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
@if (t.IsActive)
|
||||||
|
{
|
||||||
|
<form asp-action="GenerateNow" asp-route-id="@t.Id" method="post" style="display:inline;"
|
||||||
|
onsubmit="return confirm('Generate one occurrence now?')">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-lightning-charge"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover align-middle">
|
<table class="table table-hover align-middle">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
|
|||||||
@@ -65,6 +65,77 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card border-0 shadow-sm">
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="mobile-card-view">
|
||||||
|
<div class="mobile-card-list">
|
||||||
|
@foreach (var note in Model)
|
||||||
|
{
|
||||||
|
<div class="mobile-data-card">
|
||||||
|
<div class="mobile-card-header">
|
||||||
|
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #4f46e5 0%, #3730a3 100%);">
|
||||||
|
<i class="bi bi-journal-text"></i>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-title">
|
||||||
|
<h6><code>v@(note.Version)</code> — @note.Title</h6>
|
||||||
|
<small>
|
||||||
|
<span class="badge @TagBadge(note.Tag)">@note.Tag</span>
|
||||||
|
|
||||||
|
@if (note.IsPublished)
|
||||||
|
{
|
||||||
|
<span class="badge bg-success">Published</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="badge bg-warning text-dark">Draft</span>
|
||||||
|
}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-body">
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Released</span>
|
||||||
|
<span class="mobile-card-value text-muted">@note.ReleasedAt.ToString("MM/dd/yyyy")</span>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Created By</span>
|
||||||
|
<span class="mobile-card-value text-muted">@note.CreatedByUserName</span>
|
||||||
|
</div>
|
||||||
|
@if (note.Body.Length > 0)
|
||||||
|
{
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Preview</span>
|
||||||
|
<span class="mobile-card-value text-muted">@(note.Body.Length > 60 ? note.Body[..60] + "…" : note.Body)</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-footer">
|
||||||
|
<a asp-action="Edit" asp-route-id="@note.Id" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</a>
|
||||||
|
<form asp-action="TogglePublish" asp-route-id="@note.Id" method="post" class="d-inline">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<button type="submit" class="btn btn-sm @(note.IsPublished ? "btn-outline-warning" : "btn-outline-success")">
|
||||||
|
<i class="bi @(note.IsPublished ? "bi-eye-slash" : "bi-eye")"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form asp-action="Delete" asp-route-id="@note.Id" method="post" class="d-inline"
|
||||||
|
onsubmit="return confirm('Delete v@(note.Version)?')">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (!Model.Any())
|
||||||
|
{
|
||||||
|
<div class="text-center text-muted py-5">
|
||||||
|
<i class="bi bi-journal-x fs-1 d-block mb-2 opacity-25"></i>
|
||||||
|
No release notes yet. <a asp-action="Create">Create the first one.</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover align-middle mb-0 small">
|
<table class="table table-hover align-middle mb-0 small">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user