Fix oven batch conversion, invoice quantity, AI photo pricing, and enforce pricing flag propagation

- Carry OvenBatches/OvenCycleMinutes from Quote → Job entity (was missing fields; all job pricing recalcs hardcoded 1/null)
- Fix invoice creation from job always showing Quantity=1 (was using TotalPrice as UnitPrice with qty 1)
- Add IsAiItem to JobItem + migration; map in all 3 JobItemAssemblyService.CreateJobItem overloads so AI photo jobs no longer double-price on first edit after quote→job conversion
- Propagate IsAiItem through all existingItemsData JSON blocks in Jobs views (Edit, EditItems, Create) so the wizard preserves AI routing on re-edit
- Add PricingRoutingFlags_ExistOnBothQuoteItemAndJobItem structural test + 3 behavioral IsAiItem tests to JobItemAssemblyServiceTests
- Consolidate item wizard partials (_ItemWizardModal, _SqFtCalculatorModal) and item-wizard.css into shared locations
- Document pricing flag propagation checklist in CLAUDE.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 16:54:22 -04:00
parent 7e79a13cb1
commit 539c6c2559
24 changed files with 22175 additions and 994 deletions
+21
View File
@@ -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 13 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
@@ -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();
} }
@@ -21,6 +21,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,
@@ -106,6 +107,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,
@@ -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; }
+4
View File
@@ -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; }
@@ -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; }
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));
}
}
}
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));
}
}
}
@@ -4205,9 +4205,15 @@ 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<int?>("QuoteId") b.Property<int?>("QuoteId")
.HasColumnType("int"); .HasColumnType("int");
@@ -4476,6 +4482,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 +6708,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, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7475),
Description = "Standard pricing for regular customers", Description = "Standard pricing for regular customers",
DiscountPercent = 0m, DiscountPercent = 0m,
IsActive = true, IsActive = true,
@@ -6710,7 +6719,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, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7481),
Description = "5% discount for preferred customers", Description = "5% discount for preferred customers",
DiscountPercent = 5m, DiscountPercent = 5m,
IsActive = true, IsActive = true,
@@ -6721,7 +6730,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, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7482),
Description = "10% discount for premium customers", Description = "10% discount for premium customers",
DiscountPercent = 10m, DiscountPercent = 10m,
IsActive = true, IsActive = true,
@@ -396,13 +396,13 @@ 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", Description = item.Description ?? "Powder Coating",
Quantity = 1, Quantity = item.Quantity > 0 ? item.Quantity : 1,
UnitPrice = item.TotalPrice, UnitPrice = item.UnitPrice,
TotalPrice = item.TotalPrice, TotalPrice = item.TotalPrice,
ColorName = item.ColorName, ColorName = item.ColorName,
DisplayOrder = order++, DisplayOrder = order++,
RevenueAccountId = revenueAccountId RevenueAccountId = revenueAccountId
}); });
} }
@@ -461,7 +461,7 @@ public class JobsController : Controller
breakdownItems, job.CompanyId, job.CustomerId, breakdownItems, job.CompanyId, job.CustomerId,
wizardCosts?.TaxPercent ?? 0m, wizardCosts?.TaxPercent ?? 0m,
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob, job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
job.OvenCostId, 1, null); job.OvenCostId, job.OvenBatches, job.OvenCycleMinutes);
ViewBag.JobPricingBreakdown = new QuotePricingBreakdownDto ViewBag.JobPricingBreakdown = new QuotePricingBreakdownDto
{ {
@@ -506,6 +506,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 +1107,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,
@@ -1170,7 +1172,7 @@ public class JobsController : Controller
var totals = await _pricingService.CalculateQuoteTotalsAsync( var totals = await _pricingService.CalculateQuoteTotalsAsync(
dto.JobItems, companyId, dto.CustomerId, dto.JobItems, companyId, dto.CustomerId,
createCosts?.TaxPercent ?? 0m, createCosts?.TaxPercent ?? 0m,
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, job.OvenCostId, 1, null); dto.DiscountType, dto.DiscountValue, dto.IsRushJob, job.OvenCostId, job.OvenBatches, job.OvenCycleMinutes);
job.FinalPrice = totals.Total; job.FinalPrice = totals.Total;
job.OvenBatchCost = totals.OvenBatchCost; job.OvenBatchCost = totals.OvenBatchCost;
@@ -1262,6 +1264,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,
@@ -1629,7 +1632,7 @@ public class JobsController : Controller
var totals = await _pricingService.CalculateQuoteTotalsAsync( var totals = await _pricingService.CalculateQuoteTotalsAsync(
dto.JobItems, companyId, dto.CustomerId, dto.JobItems, companyId, dto.CustomerId,
editCosts?.TaxPercent ?? 0m, editCosts?.TaxPercent ?? 0m,
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, job.OvenCostId, 1, null); dto.DiscountType, dto.DiscountValue, dto.IsRushJob, job.OvenCostId, 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;
@@ -2926,6 +2929,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 +2959,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 = 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);
@@ -3040,7 +3047,7 @@ public class JobsController : Controller
// Calculate full total (overhead, margins, tax) to match what the wizard displays // Calculate full total (overhead, margins, tax) to match what the wizard displays
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); model.TaxPercent, "None", 0, false, job.OvenCostId, job.OvenBatches, job.OvenCycleMinutes);
job.FinalPrice = totals.Total; job.FinalPrice = totals.Total;
job.OvenBatchCost = totals.OvenBatchCost; job.OvenBatchCost = totals.OvenBatchCost;
@@ -3101,6 +3108,7 @@ public class JobsController : Controller
CatalogItemId = ji.CatalogItemId, CatalogItemId = ji.CatalogItemId,
IsGenericItem = ji.IsGenericItem, IsGenericItem = ji.IsGenericItem,
IsLaborItem = ji.IsLaborItem, IsLaborItem = ji.IsLaborItem,
IsAiItem = ji.IsAiItem,
ManualUnitPrice = ji.ManualUnitPrice, ManualUnitPrice = ji.ManualUnitPrice,
Coats = ji.Coats.Select(c => new CreateQuoteItemCoatDto Coats = ji.Coats.Select(c => new CreateQuoteItemCoatDto
{ {
@@ -2839,7 +2839,9 @@ 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,
+11 -133
View File
@@ -1,4 +1,4 @@
@model PowderCoating.Application.DTOs.Job.CreateJobDto @model PowderCoating.Application.DTOs.Job.CreateJobDto
@using PowderCoating.Core.Entities @using PowderCoating.Core.Entities
@{ @{
@@ -19,7 +19,7 @@
<i class="bi bi-layout-text-window-reverse fs-5"></i> <i class="bi bi-layout-text-window-reverse fs-5"></i>
<div> <div>
Pre-filled from template <strong>@ViewBag.TemplateName</strong>. Pre-filled from template <strong>@ViewBag.TemplateName</strong>.
Items and coatings have been loaded review and adjust before saving. Items and coatings have been loaded — review and adjust before saving.
</div> </div>
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert"></button> <button type="button" class="btn-close ms-auto" data-bs-dismiss="alert"></button>
</div> </div>
@@ -50,7 +50,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 Details" data-bs-title="Job Details"
data-bs-content="Core job information. Priority and due date are visible on the shop floor board and affect how work is sorted. Customer PO is the customer's own reference number for their purchase order include it so it appears on invoices. Special Instructions go directly to the shop floor worker."> data-bs-content="Core job information. Priority and due date are visible on the shop floor board and affect how work is sorted. Customer PO is the customer's own reference number for their purchase order — include it so it appears on invoices. Special Instructions go directly to the shop floor worker.">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</div> </div>
@@ -70,7 +70,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 Priority" data-bs-title="Job Priority"
data-bs-content="Controls sort order on the shop floor board and job list. Rush and Urgent jobs are highlighted in red/orange. Normal is the default. Raise priority only when the customer has an actual deadline constraint overuse of Rush dilutes its meaning for the shop floor team."> data-bs-content="Controls sort order on the shop floor board and job list. Rush and Urgent jobs are highlighted in red/orange. Normal is the default. Raise priority only when the customer has an actual deadline constraint — overuse of Rush dilutes its meaning for the shop floor team.">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</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="Due Date" data-bs-title="Due Date"
data-bs-content="The customer's deadline when the work must be ready for pickup or delivery. Overdue jobs (past due date and not yet completed) are highlighted in red on the job list."> data-bs-content="The customer's deadline — when the work must be ready for pickup or delivery. Overdue jobs (past due date and not yet completed) are highlighted in red on the job list.">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</div> </div>
@@ -130,7 +130,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="Special Instructions" data-bs-title="Special Instructions"
data-bs-content="Free-text notes visible to the shop floor worker on the work order. Use this for masking requirements, handling notes, customer preferences, or anything that doesn't fit in the item-level notes e.g., 'Keep brackets separated, customer allergic to zinc primer'."> data-bs-content="Free-text notes visible to the shop floor worker on the work order. Use this for masking requirements, handling notes, customer preferences, or anything that doesn't fit in the item-level notes — e.g., 'Keep brackets separated, customer allergic to zinc primer'.">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</div> </div>
@@ -201,7 +201,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 Items" data-bs-title="Job Items"
data-bs-content="Each item represents a physical piece being coated. Use the wizard to pick from the catalog, enter custom dimensions, or upload a photo for AI analysis. Each item gets its own coating specification color, powder, finish, and cure details. You can add multiple coating passes per item for multi-color or primer+topcoat work."> data-bs-content="Each item represents a physical piece being coated. Use the wizard to pick from the catalog, enter custom dimensions, or upload a photo for AI analysis. Each item gets its own coating specification — color, powder, finish, and cure details. You can add multiple coating passes per item for multi-color or primer+topcoat work.">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</div> </div>
@@ -276,7 +276,7 @@
<p class="mb-1 text-muted small" id="pricingPlaceholder">Pricing will update automatically as you add items.</p> <p class="mb-1 text-muted small" id="pricingPlaceholder">Pricing will update automatically as you add items.</p>
<p class="mb-1 d-none" id="itemsSubtotalRow">Items Subtotal: <strong id="itemsSubtotalDisplay">$0.00</strong></p> <p class="mb-1 d-none" id="itemsSubtotalRow">Items Subtotal: <strong id="itemsSubtotalDisplay">$0.00</strong></p>
<p class="mb-1 d-none" id="ovenBatchCostRow"> <p class="mb-1 d-none" id="ovenBatchCostRow">
<i class="bi bi-fire me-1"></i>Oven (<span id="ovenBatchesDisplay">1</span> batch × <span id="ovenCycleMinDisplay">45</span> min): <i class="bi bi-fire me-1"></i>Oven (<span id="ovenBatchesDisplay">1</span> batch × <span id="ovenCycleMinDisplay">45</span> min):
<strong id="ovenBatchCostDisplay">$0.00</strong> <strong id="ovenBatchCostDisplay">$0.00</strong>
</p> </p>
<p class="mb-1 text-success d-none" id="pricingTierDiscountRow"> <p class="mb-1 text-success d-none" id="pricingTierDiscountRow">
@@ -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 {
+85 -196
View File
@@ -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@(!string.IsNullOrEmpty(coat.ColorName) ? $" � {coat.ColorName}" : "")</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>@(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 &times; W &divide; @(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">
+11 -132
View File
@@ -1,4 +1,4 @@
@model PowderCoating.Application.DTOs.Job.UpdateJobDto @model PowderCoating.Application.DTOs.Job.UpdateJobDto
@using PowderCoating.Core.Entities @using PowderCoating.Core.Entities
@{ @{
@@ -26,7 +26,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 Details" data-bs-title="Job Details"
data-bs-content="Core job information. Priority and due date are visible on the shop floor board and job list. Customer PO is the customer's own reference number it appears on invoices. Special Instructions go directly to the shop floor worker on the work order."> data-bs-content="Core job information. Priority and due date are visible on the shop floor board and job list. Customer PO is the customer's own reference number — it appears on invoices. Special Instructions go directly to the shop floor worker on the work order.">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</div> </div>
@@ -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>
@@ -57,7 +57,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 Priority" data-bs-title="Job Priority"
data-bs-content="Controls sort order on the shop floor board and job list. Rush and Urgent jobs are highlighted in red/orange. Normal is the default. Raise priority only when the customer has an actual deadline constraint overuse of Rush dilutes its meaning for the shop floor team."> data-bs-content="Controls sort order on the shop floor board and job list. Rush and Urgent jobs are highlighted in red/orange. Normal is the default. Raise priority only when the customer has an actual deadline constraint — overuse of Rush dilutes its meaning for the shop floor team.">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</label> </label>
@@ -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="Due Date" data-bs-title="Due Date"
data-bs-content="The customer's deadline when the work must be ready for pickup or delivery. Overdue jobs (past due date and not yet completed) are highlighted in red on the job list."> data-bs-content="The customer's deadline — when the work must be ready for pickup or delivery. Overdue jobs (past due date and not yet completed) are highlighted in red on the job list.">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</label> </label>
@@ -170,7 +170,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 Items" data-bs-title="Job Items"
data-bs-content="Each item represents a physical piece being coated. Use the wizard to pick from the catalog, enter custom dimensions, or upload a photo for AI analysis. Each item gets its own coating specification color, powder, finish, and cure details. You can add multiple coating passes per item for multi-color or primer+topcoat work."> data-bs-content="Each item represents a physical piece being coated. Use the wizard to pick from the catalog, enter custom dimensions, or upload a photo for AI analysis. Each item gets its own coating specification — color, powder, finish, and cure details. You can add multiple coating passes per item for multi-color or primer+topcoat work.">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</div> </div>
@@ -245,7 +245,7 @@
<p class="mb-1 text-muted small" id="pricingPlaceholder">Pricing will update automatically as you add items.</p> <p class="mb-1 text-muted small" id="pricingPlaceholder">Pricing will update automatically as you add items.</p>
<p class="mb-1 d-none" id="itemsSubtotalRow">Items Subtotal: <strong id="itemsSubtotalDisplay">$0.00</strong></p> <p class="mb-1 d-none" id="itemsSubtotalRow">Items Subtotal: <strong id="itemsSubtotalDisplay">$0.00</strong></p>
<p class="mb-1 d-none" id="ovenBatchCostRow"> <p class="mb-1 d-none" id="ovenBatchCostRow">
<i class="bi bi-fire me-1"></i>Oven (<span id="ovenBatchesDisplay">1</span> batch × <span id="ovenCycleMinDisplay">45</span> min): <i class="bi bi-fire me-1"></i>Oven (<span id="ovenBatchesDisplay">1</span> batch × <span id="ovenCycleMinDisplay">45</span> min):
<strong id="ovenBatchCostDisplay">$0.00</strong> <strong id="ovenBatchCostDisplay">$0.00</strong>
</p> </p>
<p class="mb-1 text-success d-none" id="pricingTierDiscountRow"> <p class="mb-1 text-success d-none" id="pricingTierDiscountRow">
@@ -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 {
+11 -132
View File
@@ -1,8 +1,8 @@
@model PowderCoating.Application.DTOs.Job.JobEditItemsViewModel @model PowderCoating.Application.DTOs.Job.JobEditItemsViewModel
@using PowderCoating.Core.Entities @using PowderCoating.Core.Entities
@{ @{
ViewData["Title"] = $"Edit Items {Model.JobNumber}"; ViewData["Title"] = $"Edit Items — {Model.JobNumber}";
ViewData["PageIcon"] = "bi-list-check"; ViewData["PageIcon"] = "bi-list-check";
} }
@@ -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)
{ {
@@ -64,7 +67,7 @@
<p class="mb-1 text-muted small" id="pricingPlaceholder">Pricing will update automatically as you add items.</p> <p class="mb-1 text-muted small" id="pricingPlaceholder">Pricing will update automatically as you add items.</p>
<p class="mb-1 d-none" id="itemsSubtotalRow">Items Subtotal: <strong id="itemsSubtotalDisplay">$0.00</strong></p> <p class="mb-1 d-none" id="itemsSubtotalRow">Items Subtotal: <strong id="itemsSubtotalDisplay">$0.00</strong></p>
<p class="mb-1 d-none" id="ovenBatchCostRow"> <p class="mb-1 d-none" id="ovenBatchCostRow">
<i class="bi bi-fire me-1"></i>Oven (<span id="ovenBatchesDisplay">1</span> batch × <span id="ovenCycleMinDisplay">45</span> min): <i class="bi bi-fire me-1"></i>Oven (<span id="ovenBatchesDisplay">1</span> batch × <span id="ovenCycleMinDisplay">45</span> min):
<strong id="ovenBatchCostDisplay">$0.00</strong> <strong id="ovenBatchCostDisplay">$0.00</strong>
</p> </p>
<p class="mb-1 text-success d-none" id="pricingTierDiscountRow"> <p class="mb-1 text-success d-none" id="pricingTierDiscountRow">
@@ -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 {
+16 -192
View File
@@ -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 &lt;strong&gt;Existing Customer&lt;/strong&gt; if this person is already in your system. Choose &lt;strong&gt;New Prospect/Walk-In&lt;/strong&gt; 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.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#prospect-conversion' target='_blank'&gt;Learn more &lt;/a&gt;"> data-bs-content="Choose &lt;strong&gt;Existing Customer&lt;/strong&gt; if this person is already in your system. Choose &lt;strong&gt;New Prospect/Walk-In&lt;/strong&gt; 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.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#prospect-conversion' target='_blank'&gt;Learn more →&lt;/a&gt;">
<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 &lt;strong&gt;Expiration Date&lt;/strong&gt; is shown to the customer once it passes the quote is flagged Expired and can no longer be approved without editing. The &lt;strong&gt;Customer PO&lt;/strong&gt; field is optional use it if the customer provides their own purchase order number.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-statuses' target='_blank'&gt;Learn more &lt;/a&gt;"> data-bs-content="Set the quote date, expiration, and any internal notes. The &lt;strong&gt;Expiration Date&lt;/strong&gt; is shown to the customer — once it passes the quote is flagged Expired and can no longer be approved without editing. The &lt;strong&gt;Customer PO&lt;/strong&gt; field is optional — use it if the customer provides their own purchase order number.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-statuses' target='_blank'&gt;Learn more →&lt;/a&gt;">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</h5> </h5>
@@ -210,7 +210,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="Oven &amp; Batch Pricing" data-bs-title="Oven &amp; Batch Pricing"
data-bs-content="The oven cost is charged once per batch at the quote level, not per item. Estimate how many oven loads the full job will fill for example, if you have 20 small parts and your oven fits 10, that's 2 batches. Cycle time is how long each batch runs. The cost is calculated from your oven's hourly rate in Settings."> data-bs-content="The oven cost is charged once per batch at the quote level, not per item. Estimate how many oven loads the full job will fill — for example, if you have 20 small parts and your oven fits 10, that's 2 batches. Cycle time is how long each batch runs. The cost is calculated from your oven's hourly rate in Settings.">
<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="&lt;strong&gt;Calculated&lt;/strong&gt; you enter surface area (sq ft) and the system prices it using your rates for materials, labour, and overhead.&lt;br&gt;&lt;strong&gt;Custom Work&lt;/strong&gt; you enter a description and a manual price. Use this for flat-rate jobs or work that doesn't fit the formula.&lt;br&gt;&lt;strong&gt;AI Photo&lt;/strong&gt; upload photos and let the AI estimate surface area and complexity for you.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-items' target='_blank'&gt;Learn more &lt;/a&gt;"> data-bs-content="&lt;strong&gt;Calculated&lt;/strong&gt; — you enter surface area (sq ft) and the system prices it using your rates for materials, labour, and overhead.&lt;br&gt;&lt;strong&gt;Custom Work&lt;/strong&gt; — you enter a description and a manual price. Use this for flat-rate jobs or work that doesn't fit the formula.&lt;br&gt;&lt;strong&gt;AI Photo&lt;/strong&gt; — upload photos and let the AI estimate surface area and complexity for you.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-items' target='_blank'&gt;Learn more →&lt;/a&gt;">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</h5> </h5>
@@ -314,7 +314,7 @@
<div class="form-check"> <div class="form-check">
<input asp-for="HideDiscountFromCustomer" class="form-check-input" type="checkbox" id="hideDiscountFromCustomer" /> <input asp-for="HideDiscountFromCustomer" class="form-check-input" type="checkbox" id="hideDiscountFromCustomer" />
<label class="form-check-label small" for="hideDiscountFromCustomer"> <label class="form-check-label small" for="hideDiscountFromCustomer">
Hide discount from customer PDFs and approval portal show final price only Hide discount from customer — PDFs and approval portal show final price only
</label> </label>
</div> </div>
</div> </div>
@@ -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 &lt;strong&gt;Tier Discount&lt;/strong&gt; appears automatically if the customer has a pricing tier assigned. A &lt;strong&gt;Rush Fee&lt;/strong&gt; is added when Rush Job is checked.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#pricing-breakdown' target='_blank'&gt;Learn more &lt;/a&gt;"> 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 &lt;strong&gt;Tier Discount&lt;/strong&gt; appears automatically if the customer has a pricing tier assigned. A &lt;strong&gt;Rush Fee&lt;/strong&gt; is added when Rush Job is checked.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#pricing-breakdown' target='_blank'&gt;Learn more →&lt;/a&gt;">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</h5> </h5>
@@ -340,7 +340,7 @@
<p class="mb-1 text-muted small" id="pricingPlaceholder">Pricing will update automatically as you add items.</p> <p class="mb-1 text-muted small" id="pricingPlaceholder">Pricing will update automatically as you add items.</p>
<p class="mb-1 d-none" id="itemsSubtotalRow">Items Subtotal: <strong id="itemsSubtotalDisplay">$0.00</strong></p> <p class="mb-1 d-none" id="itemsSubtotalRow">Items Subtotal: <strong id="itemsSubtotalDisplay">$0.00</strong></p>
<p class="mb-1 d-none" id="ovenBatchCostRow"> <p class="mb-1 d-none" id="ovenBatchCostRow">
<i class="bi bi-fire me-1"></i>Oven (<span id="ovenBatchesDisplay">1</span> batch × <span id="ovenCycleMinDisplay">45</span> min): <i class="bi bi-fire me-1"></i>Oven (<span id="ovenBatchesDisplay">1</span> batch × <span id="ovenCycleMinDisplay">45</span> min):
<strong id="ovenBatchCostDisplay">$0.00</strong> <strong id="ovenBatchCostDisplay">$0.00</strong>
</p> </p>
<p class="mb-1 text-success d-none" id="pricingTierDiscountRow"> <p class="mb-1 text-success d-none" id="pricingTierDiscountRow">
@@ -379,7 +379,7 @@
<div class="row g-3" id="stagedPhotoGrid"></div> <div class="row g-3" id="stagedPhotoGrid"></div>
<div id="stagedPhotoUploadProgress" class="d-none mt-2"> <div id="stagedPhotoUploadProgress" class="d-none mt-2">
<div class="progress"><div class="progress-bar progress-bar-striped progress-bar-animated" style="width:100%"></div></div> <div class="progress"><div class="progress-bar progress-bar-striped progress-bar-animated" style="width:100%"></div></div>
<small class="text-muted">Uploading</small> <small class="text-muted">Uploading…</small>
</div> </div>
</div> </div>
</div> </div>
@@ -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');
@@ -690,7 +560,7 @@
function applyMode(mode) { function applyMode(mode) {
if (mode === 'simple') { if (mode === 'simple') {
form.classList.add('quote-simple-mode'); form.classList.add('quote-simple-mode');
hint.textContent = 'Advanced fields are hidden switch to Full Quote to see them.'; hint.textContent = 'Advanced fields are hidden — switch to Full Quote to see them.';
} else { } else {
form.classList.remove('quote-simple-mode'); form.classList.remove('quote-simple-mode');
hint.textContent = ''; hint.textContent = '';
@@ -758,8 +628,8 @@
smsNote.style.display = 'inline'; smsNote.style.display = 'inline';
smsNote.className = hasSms ? 'badge bg-info text-white' : 'badge bg-warning text-dark'; smsNote.className = hasSms ? 'badge bg-info text-white' : 'badge bg-warning text-dark';
smsNote.innerHTML = hasSms smsNote.innerHTML = hasSms
? '<i class="bi bi-phone me-1"></i>No email send via SMS from quote details' ? '<i class="bi bi-phone me-1"></i>No email — send via SMS from quote details'
: '<i class="bi bi-phone-slash me-1"></i>No email SMS consent required'; : '<i class="bi bi-phone-slash me-1"></i>No email — SMS consent required';
} else { } else {
smsNote.style.display = 'none'; smsNote.style.display = 'none';
} }
@@ -803,52 +673,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) {
+17 -189
View File
@@ -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 &lt;strong&gt;Expiration Date&lt;/strong&gt; is shown to the customer once it passes the quote is flagged Expired and can no longer be approved without editing. The &lt;strong&gt;Customer PO&lt;/strong&gt; field is optional use it if the customer provides their own purchase order number.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-statuses' target='_blank'&gt;Learn more &lt;/a&gt;"> data-bs-content="Set the quote date, expiration, and any internal notes. The &lt;strong&gt;Expiration Date&lt;/strong&gt; is shown to the customer — once it passes the quote is flagged Expired and can no longer be approved without editing. The &lt;strong&gt;Customer PO&lt;/strong&gt; field is optional — use it if the customer provides their own purchase order number.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-statuses' target='_blank'&gt;Learn more →&lt;/a&gt;">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</h5> </h5>
@@ -173,7 +173,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="Oven &amp; Batch Pricing" data-bs-title="Oven &amp; Batch Pricing"
data-bs-content="The oven cost is charged once per batch at the quote level, not per item. Estimate how many oven loads the full job will fill for example, if you have 20 small parts and your oven fits 10, that's 2 batches. Cycle time is how long each batch runs. The cost is calculated from your oven's hourly rate in Settings."> data-bs-content="The oven cost is charged once per batch at the quote level, not per item. Estimate how many oven loads the full job will fill — for example, if you have 20 small parts and your oven fits 10, that's 2 batches. Cycle time is how long each batch runs. The cost is calculated from your oven's hourly rate in Settings.">
<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="&lt;strong&gt;Calculated&lt;/strong&gt; you enter surface area (sq ft) and the system prices it using your rates for materials, labour, and overhead.&lt;br&gt;&lt;strong&gt;Custom Work&lt;/strong&gt; you enter a description and a manual price. Use this for flat-rate jobs or work that doesn't fit the formula.&lt;br&gt;&lt;strong&gt;AI Photo&lt;/strong&gt; upload photos and let the AI estimate surface area and complexity for you.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-items' target='_blank'&gt;Learn more &lt;/a&gt;"> data-bs-content="&lt;strong&gt;Calculated&lt;/strong&gt; — you enter surface area (sq ft) and the system prices it using your rates for materials, labour, and overhead.&lt;br&gt;&lt;strong&gt;Custom Work&lt;/strong&gt; — you enter a description and a manual price. Use this for flat-rate jobs or work that doesn't fit the formula.&lt;br&gt;&lt;strong&gt;AI Photo&lt;/strong&gt; — upload photos and let the AI estimate surface area and complexity for you.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-items' target='_blank'&gt;Learn more →&lt;/a&gt;">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</h5> </h5>
@@ -277,7 +277,7 @@
<div class="form-check"> <div class="form-check">
<input asp-for="HideDiscountFromCustomer" class="form-check-input" type="checkbox" id="hideDiscountFromCustomer" /> <input asp-for="HideDiscountFromCustomer" class="form-check-input" type="checkbox" id="hideDiscountFromCustomer" />
<label class="form-check-label small" for="hideDiscountFromCustomer"> <label class="form-check-label small" for="hideDiscountFromCustomer">
Hide discount from customer PDFs and approval portal show final price only Hide discount from customer — PDFs and approval portal show final price only
</label> </label>
</div> </div>
</div> </div>
@@ -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 &lt;strong&gt;Tier Discount&lt;/strong&gt; appears automatically if the customer has a pricing tier assigned. A &lt;strong&gt;Rush Fee&lt;/strong&gt; is added when Rush Job is checked.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#pricing-breakdown' target='_blank'&gt;Learn more &lt;/a&gt;"> 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 &lt;strong&gt;Tier Discount&lt;/strong&gt; appears automatically if the customer has a pricing tier assigned. A &lt;strong&gt;Rush Fee&lt;/strong&gt; is added when Rush Job is checked.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#pricing-breakdown' target='_blank'&gt;Learn more →&lt;/a&gt;">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</h5> </h5>
@@ -303,7 +303,7 @@
<p class="mb-1 text-muted small" id="pricingPlaceholder">Pricing will update automatically as you add items.</p> <p class="mb-1 text-muted small" id="pricingPlaceholder">Pricing will update automatically as you add items.</p>
<p class="mb-1 d-none" id="itemsSubtotalRow">Items Subtotal: <strong id="itemsSubtotalDisplay">$0.00</strong></p> <p class="mb-1 d-none" id="itemsSubtotalRow">Items Subtotal: <strong id="itemsSubtotalDisplay">$0.00</strong></p>
<p class="mb-1 d-none" id="ovenBatchCostRow"> <p class="mb-1 d-none" id="ovenBatchCostRow">
<i class="bi bi-fire me-1"></i>Oven (<span id="ovenBatchesDisplay">1</span> batch × <span id="ovenCycleMinDisplay">45</span> min): <i class="bi bi-fire me-1"></i>Oven (<span id="ovenBatchesDisplay">1</span> batch × <span id="ovenCycleMinDisplay">45</span> min):
<strong id="ovenBatchCostDisplay">$0.00</strong> <strong id="ovenBatchCostDisplay">$0.00</strong>
</p> </p>
<p class="mb-1 text-success d-none" id="pricingTierDiscountRow"> <p class="mb-1 text-success d-none" id="pricingTierDiscountRow">
@@ -407,7 +407,7 @@
</div> </div>
<div id="editPhotoUploadProgress" class="d-none mt-2"> <div id="editPhotoUploadProgress" class="d-none mt-2">
<div class="progress"><div class="progress-bar progress-bar-striped progress-bar-animated" style="width:100%"></div></div> <div class="progress"><div class="progress-bar progress-bar-striped progress-bar-animated" style="width:100%"></div></div>
<small class="text-muted">Uploading</small> <small class="text-muted">Uploading…</small>
</div> </div>
</div> </div>
</div> </div>
@@ -435,11 +435,11 @@
<span id="smsNotifyNote" class="badge @(editHasSms ? "bg-info text-white" : "bg-warning text-dark")"> <span id="smsNotifyNote" class="badge @(editHasSms ? "bg-info text-white" : "bg-warning text-dark")">
@if (editHasSms) @if (editHasSms)
{ {
<i class="bi bi-phone me-1"></i><text>No email send via SMS from quote details</text> <i class="bi bi-phone me-1"></i><text>No email — send via SMS from quote details</text>
} }
else else
{ {
<i class="bi bi-phone-slash me-1"></i><text>No email SMS consent required</text> <i class="bi bi-phone-slash me-1"></i><text>No email — SMS consent required</text>
} }
</span> </span>
} }
@@ -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)
@@ -579,7 +488,7 @@
@Html.Raw(Json.Serialize(ViewBag.BlastSetups ?? new List<object>())) @Html.Raw(Json.Serialize(ViewBag.BlastSetups ?? new List<object>()))
</script> </script>
<!-- Existing items always populated on Edit --> <!-- Existing items — always populated on Edit -->
<script id="existingItemsData" type="application/json"> <script id="existingItemsData" type="application/json">
@Html.Raw(System.Text.Json.JsonSerializer.Serialize((Model.QuoteItems ?? new List<PowderCoating.Application.DTOs.Quote.CreateQuoteItemDto>()).Select((item, i) => new { @Html.Raw(System.Text.Json.JsonSerializer.Serialize((Model.QuoteItems ?? new List<PowderCoating.Application.DTOs.Quote.CreateQuoteItemDto>()).Select((item, i) => new {
description = item.Description, description = item.Description,
@@ -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 {
@@ -740,8 +613,8 @@
smsNote.style.display = 'inline'; smsNote.style.display = 'inline';
smsNote.className = hasSms ? 'badge bg-info text-white' : 'badge bg-warning text-dark'; smsNote.className = hasSms ? 'badge bg-info text-white' : 'badge bg-warning text-dark';
smsNote.innerHTML = hasSms smsNote.innerHTML = hasSms
? '<i class="bi bi-phone me-1"></i>No email send via SMS from quote details' ? '<i class="bi bi-phone me-1"></i>No email — send via SMS from quote details'
: '<i class="bi bi-phone-slash me-1"></i>No email SMS consent required'; : '<i class="bi bi-phone-slash me-1"></i>No email — SMS consent required';
} else { } else {
smsNote.style.display = 'none'; smsNote.style.display = 'none';
} }
@@ -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) {
@@ -811,7 +639,7 @@
} }
}); });
// Quote photo direct upload (Edit page quoteId is known) // Quote photo direct upload (Edit page — quoteId is known)
(function () { (function () {
const quoteId = @Model.Id; const quoteId = @Model.Id;
const uploadUrl = '@Url.Action("UploadQuotePhoto", "Quotes")'; const uploadUrl = '@Url.Action("UploadQuotePhoto", "Quotes")';
@@ -0,0 +1,38 @@
<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>
@@ -0,0 +1,63 @@
@{
var useMetric = ViewBag.UseMetric == true;
var unit = useMetric ? "cm" : "in";
var divisorLabel = useMetric ? "10,000" : "144";
var areaUnit = (string?)ViewBag.AreaUnit ?? "sq ft";
}
<div class="modal fade" id="sqFtCalculatorModal" tabindex="-1">
<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 (@unit)</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 (@unit)</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 &times; W &divide; @divisorLabel</small>
</div>
<div id="cylinderInputs" style="display:none">
<div class="row g-2">
<div class="col-6">
<label class="form-label">Diameter (@unit)</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 (@unit)</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 (@unit)</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> @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>
@@ -0,0 +1,44 @@
/* Item Wizard — shared styles used by all views that host the wizard modal */
/* Step indicator dots and connector lines */
.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 (Step 1) */
.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 — custom scrollable list replacing a native <select> */
.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 item cards (displayed below wizard after adding items) */
.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; }
[data-bs-theme="dark"] .quote-item-card { background: var(--bs-tertiary-bg); border-color: var(--bs-border-color); color: var(--bs-body-color); }
/* Coat rows inside the wizard Step 3 */
.coat-row { border: 1px solid #dee2e6; border-radius: .5rem; padding: .75rem; margin-bottom: .5rem; }
[data-bs-theme="dark"] .coat-row { background: var(--bs-tertiary-bg); border-color: var(--bs-border-color); }
@@ -3426,3 +3426,53 @@ function loadItemsFromTemplate(templateItems) {
renderAllCards(); renderAllCards();
scheduleAutoPricing(); scheduleAutoPricing();
} }
// ── Surface area calculator modal ─────────────────────────────────────────────
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 = !!(pageMeta && pageMeta.useMetric);
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();
}
@@ -195,6 +195,106 @@ public class JobItemAssemblyServiceTests
Assert.Equal(9.5m, coat.PowderToOrder); Assert.Equal(9.5m, coat.PowderToOrder);
} }
// ─── IsAiItem propagation tests ──────────────────────────────────────────────
// AI items use ManualUnitPrice as-is and are excluded from quote-level oven cost
// (the pricing engine assumes oven is already baked into the AI estimate).
// IsAiItem MUST survive every conversion path or the job will be mispriced.
[Fact]
public void PricingRoutingFlags_ExistOnBothQuoteItemAndJobItem()
{
// These bool flags are read by PricingCalculationService to route items to the
// correct pricing path. They MUST exist on both QuoteItem and JobItem, and MUST
// be mapped by JobItemAssemblyService in all three overloads.
//
// If this test fails: you added a pricing flag to one entity but not the other.
// Fix: add the field to both entities, add it to JobItemSeed, map it in all three
// CreateJobItem overloads, and add it to the known list below.
var requiredPricingFlags = new[]
{
nameof(QuoteItem.IsGenericItem),
nameof(QuoteItem.IsLaborItem),
nameof(QuoteItem.IsSalesItem),
nameof(QuoteItem.IsAiItem),
};
foreach (var flag in requiredPricingFlags)
{
Assert.True(typeof(QuoteItem).GetProperty(flag) != null,
$"QuoteItem is missing pricing flag '{flag}' — add it or remove it from this list.");
Assert.True(typeof(JobItem).GetProperty(flag) != null,
$"JobItem is missing pricing flag '{flag}' — add it to JobItem and map it in JobItemAssemblyService.");
Assert.True(typeof(CreateQuoteItemDto).GetProperty(flag) != null,
$"CreateQuoteItemDto is missing pricing flag '{flag}' — add it to the DTO and include it in all existingItemsData JSON blocks.");
}
}
[Fact]
public void CreateJobItem_FromDto_PreservesIsAiItemFlag()
{
var source = new CreateQuoteItemDto
{
Description = "AI Photo Item",
Quantity = 1m,
SurfaceAreaSqFt = 20m,
EstimatedMinutes = 45,
IsAiItem = true,
ManualUnitPrice = 500m,
Complexity = "Moderate",
Coats = [new CreateQuoteItemCoatDto { CoatName = "Coat 1", Sequence = 1 }]
};
var pricing = new QuoteItemPricingResult { UnitPrice = 500m, TotalPrice = 500m };
var jobItem = _service.CreateJobItem(source, jobId: 1, companyId: 1, pricing: pricing, createdAtUtc: CreatedAtUtc);
Assert.True(jobItem.IsAiItem,
"IsAiItem must survive DTO → JobItem conversion. Without it, saved AI jobs are repriced as " +
"calculated items on next edit and oven cost is double-charged.");
}
[Fact]
public void CreateJobItem_FromQuoteItem_PreservesIsAiItemFlag()
{
var quoteItem = new QuoteItem
{
Description = "AI Photo Item",
Quantity = 1m,
SurfaceAreaSqFt = 20m,
IsAiItem = true,
ManualUnitPrice = 500m,
UnitPrice = 500m,
TotalPrice = 500m,
Coats = [new QuoteItemCoat { CoatName = "Coat 1", Sequence = 1 }]
};
var jobItem = _service.CreateJobItem(quoteItem, jobId: 1, companyId: 1, createdAtUtc: CreatedAtUtc);
Assert.True(jobItem.IsAiItem,
"IsAiItem must survive QuoteItem → JobItem conversion (quote-approval / CreateJobFromQuote path).");
}
[Fact]
public void CreateJobItem_FromExistingJobItem_PreservesIsAiItemFlag()
{
var source = new JobItem
{
Description = "AI Photo Item",
Quantity = 1m,
SurfaceAreaSqFt = 20m,
IsAiItem = true,
ManualUnitPrice = 500m,
UnitPrice = 500m,
TotalPrice = 500m,
LaborCost = 200m,
Coats = [new JobItemCoat { CoatName = "Coat 1", Sequence = 1 }]
};
var jobItem = _service.CreateJobItem(source, jobId: 1, companyId: 1, createdAtUtc: CreatedAtUtc);
Assert.True(jobItem.IsAiItem,
"IsAiItem must survive JobItem → JobItem copy (rework path).");
}
[Fact] [Fact]
public void CreateJobItem_FromExistingJobItem_PreservesTransferableShapeForRework() public void CreateJobItem_FromExistingJobItem_PreservesTransferableShapeForRework()
{ {