Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7020797a25 | |||
| 3b5511a703 | |||
| 8df37ca760 | |||
| 7239f55308 | |||
| 09e077897b | |||
| 051c86810e | |||
| 6721de91e4 | |||
| 226a6237a6 | |||
| cf6acc125f | |||
| f467862877 | |||
| 7ad7d84016 | |||
| 75b0a8afe2 | |||
| 38748c2152 | |||
| 4ec55e7290 | |||
| 3eda91f170 | |||
| cefdf3e35c | |||
| f34ee749be | |||
| 357ef84001 | |||
| 7a1a697dc2 | |||
| 539c6c2559 | |||
| a947494cbd | |||
| 7e79a13cb1 | |||
| 2ad6df1195 | |||
| dc3cd75ea4 | |||
| a73f14fa7f | |||
| 0af31c39b3 | |||
| e1256503be | |||
| b69ff6db3a | |||
| 66231822af | |||
| d5ad9fa073 | |||
| d134dd51e5 | |||
| 1df7c13abd | |||
| 4a8778504f | |||
| f1d7054b3e | |||
| 46b950baf2 | |||
| 4e9c9d321a | |||
| 0c8723ef84 | |||
| 377bb1ce38 | |||
| 2acf54e1a9 | |||
| 0b24c320cd |
@@ -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)
|
||||
- Tax exempt customers (`Customer.IsTaxExempt`): `TaxPercent` defaults to 0 on quote and invoice create; customer dropdown marks exempt customers with ★
|
||||
|
||||
### Pricing Routing Flags — Must Stay In Sync Across All Three Layers
|
||||
|
||||
`PricingCalculationService.CalculateQuoteItemPriceAsync` routes each item to the correct pricing path using boolean flags. **These flags MUST exist identically on `QuoteItem`, `JobItem`, and `CreateQuoteItemDto`, AND be mapped in all three `JobItemAssemblyService.CreateJobItem` overloads.**
|
||||
|
||||
| Flag | Effect if missing on JobItem |
|
||||
|------|------------------------------|
|
||||
| `IsAiItem` | Job repriced as calculated item; oven cost double-charged on every save |
|
||||
| `IsGenericItem` | ManualUnitPrice ignored; price recalculated from surface area |
|
||||
| `IsLaborItem` | Item repriced at surface-area rate instead of hours × labor rate |
|
||||
| `IsSalesItem` | ManualUnitPrice ignored; item repriced using coat/surface math |
|
||||
|
||||
**Checklist when adding a new pricing routing flag:**
|
||||
1. Add the property to `QuoteItem` (Core/Entities)
|
||||
2. Add the property to `JobItem` (Core/Entities)
|
||||
3. Add it to `CreateQuoteItemDto` (Application/DTOs)
|
||||
4. Add it to `JobItemSeed` (private class in JobItemAssemblyService)
|
||||
5. Map it in all three `JobItemAssemblyService.CreateJobItem` overloads
|
||||
6. Include it in every `existingItemsData` JSON block in job views (`Edit.cshtml`, `EditItems.cshtml`) and in all job controller actions that build `CreateQuoteItemDto` from a `JobItem`
|
||||
7. Add a migration if the field is new on a persisted entity
|
||||
8. The structural test `PricingRoutingFlags_ExistOnBothQuoteItemAndJobItem` in `JobItemAssemblyServiceTests` will fail until steps 1–3 are done — this is intentional
|
||||
|
||||
### Branding
|
||||
- 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
|
||||
|
||||
@@ -59,6 +59,9 @@ public class CompanyPreferencesDto
|
||||
// Blank Work Order PDF Template
|
||||
public string WoAccentColor { get; set; } = "#374151";
|
||||
public string? WoTerms { get; set; }
|
||||
|
||||
// Kiosk settings
|
||||
public string KioskIntakeOutput { get; set; } = "Quote";
|
||||
}
|
||||
|
||||
public class UpdateAppDefaultsDto
|
||||
@@ -136,3 +139,11 @@ public class UpdateWorkOrderTemplateDto
|
||||
public string WoAccentColor { get; set; } = "#374151";
|
||||
[StringLength(2000)] public string? WoTerms { get; set; }
|
||||
}
|
||||
|
||||
|
||||
public class UpdateKioskSettingsDto
|
||||
{
|
||||
/// <summary>"Quote" (default) or "Job" — what the kiosk creates on submission.</summary>
|
||||
[Required]
|
||||
public string KioskIntakeOutput { get; set; } = "Quote";
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ public class GiftCertificateListDto
|
||||
public GiftCertificateStatus Status { get; set; }
|
||||
public DateTime IssueDate { get; set; }
|
||||
public DateTime? ExpiryDate { get; set; }
|
||||
public Guid? BatchId { get; set; }
|
||||
}
|
||||
|
||||
public class GiftCertificateDto : GiftCertificateListDto
|
||||
@@ -87,3 +88,27 @@ public class RedeemGiftCertificateDto
|
||||
[Range(0.01, 9999.99)]
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
|
||||
public class BulkCreateGiftCertificateDto
|
||||
{
|
||||
[Required]
|
||||
[Range(1, 500, ErrorMessage = "Quantity must be between 1 and 500.")]
|
||||
[Display(Name = "Number of Certificates")]
|
||||
public int Quantity { get; set; } = 25;
|
||||
|
||||
[Required]
|
||||
[Range(1.00, 9999.99, ErrorMessage = "Amount must be between $1.00 and $9,999.99.")]
|
||||
[Display(Name = "Face Value (each)")]
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
[Required]
|
||||
[Display(Name = "Issued Reason")]
|
||||
public GiftCertificateIssuedReason IssuedReason { get; set; } = GiftCertificateIssuedReason.Promotional;
|
||||
|
||||
[Display(Name = "Expiry Date (optional)")]
|
||||
public DateTime? ExpiryDate { get; set; }
|
||||
|
||||
[StringLength(1000)]
|
||||
[Display(Name = "Event / Notes (applied to all certificates)")]
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
@@ -515,6 +515,9 @@ public class JobEditItemsViewModel
|
||||
public string JobNumber { get; set; } = string.Empty;
|
||||
public int? CustomerId { 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();
|
||||
}
|
||||
|
||||
|
||||
@@ -76,12 +76,13 @@ public class KioskSessionListDto
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public int? LinkedCustomerId { get; set; }
|
||||
public int? LinkedJobId { get; set; }
|
||||
public int? LinkedQuoteId { get; set; }
|
||||
public string? RemoteLinkEmail { get; set; }
|
||||
|
||||
public string CustomerFullName => $"{CustomerFirstName} {CustomerLastName}".Trim();
|
||||
public string JobDescriptionSnippet =>
|
||||
JobDescription.Length > 80 ? JobDescription[..80] + "…" : JobDescription;
|
||||
public bool IsConverted => LinkedJobId.HasValue;
|
||||
public bool IsConverted => LinkedJobId.HasValue || LinkedQuoteId.HasValue;
|
||||
public bool IsExpired => Status == KioskSessionStatus.Expired ||
|
||||
(Status == KioskSessionStatus.Active && DateTime.UtcNow > ExpiresAt);
|
||||
}
|
||||
|
||||
@@ -604,6 +604,11 @@ public class QuotePricingBreakdownDto
|
||||
|
||||
public decimal SubtotalBeforeDiscount { get; set; }
|
||||
|
||||
public decimal PricingTierDiscountAmount { get; set; }
|
||||
public decimal PricingTierDiscountPercent { get; set; }
|
||||
public decimal QuoteDiscountAmount { get; set; }
|
||||
public decimal QuoteDiscountPercent { get; set; }
|
||||
|
||||
public decimal DiscountAmount { get; set; }
|
||||
public decimal DiscountPercent { get; set; }
|
||||
|
||||
|
||||
@@ -51,4 +51,10 @@ public interface IPdfService
|
||||
byte[]? companyLogo,
|
||||
string? companyLogoContentType,
|
||||
CompanyInfoDto companyInfo);
|
||||
|
||||
Task<byte[]> GenerateBulkGiftCertificatePdfAsync(
|
||||
IList<GiftCertificateDto> certs,
|
||||
byte[]? companyLogo,
|
||||
string? companyLogoContentType,
|
||||
CompanyInfoDto companyInfo);
|
||||
}
|
||||
|
||||
@@ -54,5 +54,6 @@ public class CompanyProfile : Profile
|
||||
CreateMap<UpdateQuoteTemplateDto, CompanyPreferences>();
|
||||
CreateMap<UpdateInvoiceTemplateDto, CompanyPreferences>();
|
||||
CreateMap<UpdateWorkOrderTemplateDto, CompanyPreferences>();
|
||||
CreateMap<UpdateKioskSettingsDto, CompanyPreferences>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,12 +21,13 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
IsGenericItem = source.IsGenericItem,
|
||||
IsLaborItem = source.IsLaborItem,
|
||||
IsSalesItem = source.IsSalesItem,
|
||||
IsAiItem = source.IsAiItem,
|
||||
Sku = source.Sku,
|
||||
ManualUnitPrice = source.ManualUnitPrice,
|
||||
PowderCostOverride = source.PowderCostOverride,
|
||||
UnitPrice = pricing.UnitPrice,
|
||||
TotalPrice = pricing.TotalPrice,
|
||||
LaborCost = pricing.TotalPrice * 0.4m,
|
||||
LaborCost = pricing.LaborCost,
|
||||
RequiresSandblasting = source.RequiresSandblasting,
|
||||
RequiresMasking = source.RequiresMasking,
|
||||
EstimatedMinutes = source.EstimatedMinutes,
|
||||
@@ -106,12 +107,13 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
IsGenericItem = source.IsGenericItem,
|
||||
IsLaborItem = source.IsLaborItem,
|
||||
IsSalesItem = source.IsSalesItem,
|
||||
IsAiItem = source.IsAiItem,
|
||||
Sku = source.Sku,
|
||||
ManualUnitPrice = source.ManualUnitPrice,
|
||||
PowderCostOverride = source.PowderCostOverride,
|
||||
UnitPrice = source.UnitPrice,
|
||||
TotalPrice = source.TotalPrice,
|
||||
LaborCost = source.TotalPrice * 0.4m,
|
||||
LaborCost = source.ItemLaborCost,
|
||||
RequiresSandblasting = source.RequiresSandblasting,
|
||||
RequiresMasking = source.RequiresMasking,
|
||||
EstimatedMinutes = source.EstimatedMinutes,
|
||||
@@ -191,6 +193,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
IsGenericItem = source.IsGenericItem,
|
||||
IsLaborItem = source.IsLaborItem,
|
||||
IsSalesItem = source.IsSalesItem,
|
||||
IsAiItem = source.IsAiItem,
|
||||
Sku = source.Sku,
|
||||
ManualUnitPrice = source.ManualUnitPrice,
|
||||
PowderCostOverride = source.PowderCostOverride,
|
||||
@@ -270,6 +273,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
IsGenericItem = seed.IsGenericItem,
|
||||
IsLaborItem = seed.IsLaborItem,
|
||||
IsSalesItem = seed.IsSalesItem,
|
||||
IsAiItem = seed.IsAiItem,
|
||||
Sku = seed.Sku,
|
||||
ManualUnitPrice = seed.ManualUnitPrice,
|
||||
PowderCostOverride = seed.PowderCostOverride,
|
||||
@@ -364,6 +368,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
public bool IsGenericItem { get; init; }
|
||||
public bool IsLaborItem { get; init; }
|
||||
public bool IsSalesItem { get; init; }
|
||||
public bool IsAiItem { get; init; }
|
||||
public string? Sku { get; init; }
|
||||
public decimal? ManualUnitPrice { get; init; }
|
||||
public decimal? PowderCostOverride { get; init; }
|
||||
|
||||
@@ -1858,6 +1858,50 @@ public class PdfService : IPdfService
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a multi-page PDF containing one gift certificate per page, all using the same
|
||||
/// branded layout as the single-certificate download. Used for bulk print runs (car shows,
|
||||
/// promotions) so staff can hand-cut and distribute a full batch from one print job.
|
||||
/// </summary>
|
||||
public async Task<byte[]> GenerateBulkGiftCertificatePdfAsync(
|
||||
IList<GiftCertificateDto> certs,
|
||||
byte[]? companyLogo,
|
||||
string? companyLogoContentType,
|
||||
CompanyInfoDto companyInfo)
|
||||
{
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
const string accent = "#7c3aed";
|
||||
const string gold = "#b45309";
|
||||
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
var doc = Document.Create(container =>
|
||||
{
|
||||
foreach (var cert in certs)
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.Letter);
|
||||
page.Margin(0.75f, Unit.Inch);
|
||||
page.PageColor(Colors.White);
|
||||
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
|
||||
|
||||
page.Content().Element(c => ComposeGiftCertificateContent(c, cert, companyInfo, companyLogo, accent, gold));
|
||||
|
||||
page.Footer().AlignCenter().Text(text =>
|
||||
{
|
||||
text.Span(companyInfo.CompanyName).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
|
||||
text.Span($" · {FormatPhoneNumber(companyInfo.Phone)}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return doc.GeneratePdf();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composes the gift certificate body with a decorative double-border frame (outer purple 3pt,
|
||||
/// inner gold 1pt) that gives the document a premium printed-certificate appearance. Inside the
|
||||
|
||||
@@ -30,23 +30,30 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
ArgumentNullException.ThrowIfNull(quote);
|
||||
ArgumentNullException.ThrowIfNull(pricingResult);
|
||||
|
||||
quote.MaterialCosts = pricingResult.MaterialCosts;
|
||||
quote.LaborCosts = pricingResult.LaborCosts;
|
||||
quote.EquipmentCosts = pricingResult.EquipmentCosts;
|
||||
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
|
||||
quote.OvenBatchCost = pricingResult.OvenBatchCost;
|
||||
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
|
||||
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
|
||||
quote.OverheadAmount = pricingResult.OverheadCosts;
|
||||
quote.OverheadPercent = pricingResult.OverheadPercent;
|
||||
quote.ProfitMargin = pricingResult.ProfitMargin;
|
||||
quote.ProfitPercent = pricingResult.ProfitPercent;
|
||||
quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
|
||||
quote.DiscountPercent = pricingResult.DiscountPercent;
|
||||
quote.DiscountAmount = pricingResult.DiscountAmount;
|
||||
quote.RushFee = pricingResult.RushFee;
|
||||
quote.TaxAmount = pricingResult.TaxAmount;
|
||||
quote.Total = pricingResult.Total;
|
||||
quote.MaterialCosts = pricingResult.MaterialCosts;
|
||||
quote.LaborCosts = pricingResult.LaborCosts;
|
||||
quote.EquipmentCosts = pricingResult.EquipmentCosts;
|
||||
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
|
||||
quote.OvenBatchCost = pricingResult.OvenBatchCost;
|
||||
quote.FacilityOverheadCost = pricingResult.FacilityOverheadCost;
|
||||
quote.FacilityOverheadRatePerHour = pricingResult.FacilityOverheadRatePerHour;
|
||||
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
|
||||
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
|
||||
quote.OverheadAmount = pricingResult.OverheadCosts;
|
||||
quote.OverheadPercent = pricingResult.OverheadPercent;
|
||||
quote.ProfitMargin = pricingResult.ProfitMargin;
|
||||
quote.ProfitPercent = pricingResult.ProfitPercent;
|
||||
quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
|
||||
quote.PricingTierDiscountAmount = pricingResult.PricingTierDiscountAmount;
|
||||
quote.PricingTierDiscountPercent = pricingResult.PricingTierDiscountPercent;
|
||||
quote.QuoteDiscountAmount = pricingResult.QuoteDiscountAmount;
|
||||
quote.QuoteDiscountPercent = pricingResult.QuoteDiscountPercent;
|
||||
quote.DiscountPercent = pricingResult.DiscountPercent;
|
||||
quote.DiscountAmount = pricingResult.DiscountAmount;
|
||||
quote.SubtotalAfterDiscount = pricingResult.SubtotalAfterDiscount;
|
||||
quote.RushFee = pricingResult.RushFee;
|
||||
quote.TaxAmount = pricingResult.TaxAmount;
|
||||
quote.Total = pricingResult.Total;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync(
|
||||
|
||||
@@ -86,6 +86,14 @@ public class CompanyPreferences : BaseEntity
|
||||
/// <summary>JSON blob persisting QB Migration Wizard step state across sessions.</summary>
|
||||
public string? QbMigrationStateJson { get; set; }
|
||||
|
||||
// Kiosk settings
|
||||
/// <summary>
|
||||
/// Controls what the kiosk creates on submission: "Quote" (default) or "Job".
|
||||
/// Quote aligns with the default Terms text ("subject to a formal quote").
|
||||
/// Job is for shops that price on the spot and want the work order ready immediately.
|
||||
/// </summary>
|
||||
public string KioskIntakeOutput { get; set; } = "Quote";
|
||||
|
||||
// Guided activation / first-workflow onboarding
|
||||
/// <summary>Selected first-workflow path: quote_first or job_first. Null until chosen.</summary>
|
||||
public string? OnboardingPath { get; set; }
|
||||
|
||||
@@ -32,6 +32,9 @@ public class GiftCertificate : BaseEntity
|
||||
/// <summary>Set when this GC was sold via an invoice line item.</summary>
|
||||
public int? SourceInvoiceItemId { get; set; }
|
||||
|
||||
/// <summary>Groups all certificates created in a single bulk run. Null for individually issued certs.</summary>
|
||||
public Guid? BatchId { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Customer? RecipientCustomer { get; set; }
|
||||
public virtual Customer? PurchasingCustomer { get; set; }
|
||||
|
||||
@@ -25,6 +25,10 @@ public class Job : BaseEntity
|
||||
// Selected oven (carried over from quote; null = company default rate)
|
||||
public int? OvenCostId { get; set; }
|
||||
|
||||
// Oven scheduling (carried over from quote)
|
||||
public int OvenBatches { get; set; } = 1;
|
||||
public int? OvenCycleMinutes { get; set; }
|
||||
|
||||
// Pricing
|
||||
public decimal QuotedPrice { get; set; }
|
||||
public decimal FinalPrice { get; set; }
|
||||
@@ -62,6 +66,10 @@ public class Job : BaseEntity
|
||||
// Used to detect when the quote was subsequently edited so the job details page can warn the user.
|
||||
public DateTime? QuoteSnapshotUpdatedAt { get; set; }
|
||||
|
||||
// Pricing snapshot — serialized QuotePricingBreakdownDto stored at save time so Details displays
|
||||
// the breakdown that was actually calculated, not a re-run against current operating costs.
|
||||
public string? PricingBreakdownJson { get; set; }
|
||||
|
||||
// Rework tracking
|
||||
public bool IsReworkJob { get; set; }
|
||||
public int? OriginalJobId { get; set; } // Set when this job was created as a rework
|
||||
|
||||
@@ -41,6 +41,10 @@ public class JobItem : BaseEntity
|
||||
// Values: "Simple" | "Moderate" | "Complex" | "Extreme"
|
||||
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")
|
||||
public string? AiTags { get; set; }
|
||||
|
||||
|
||||
@@ -36,7 +36,10 @@ public class KioskSession : BaseEntity
|
||||
|
||||
// ── Outcome ───────────────────────────────────────────────────────────────
|
||||
public int? LinkedCustomerId { get; set; }
|
||||
/// <summary>Set when KioskIntakeOutput = "Job". Null when a Quote was created instead.</summary>
|
||||
public int? LinkedJobId { get; set; }
|
||||
/// <summary>Set when KioskIntakeOutput = "Quote". Null when a Job was created instead.</summary>
|
||||
public int? LinkedQuoteId { get; set; }
|
||||
public DateTime? SubmittedAt { get; set; }
|
||||
/// <summary>Sessions auto-expire 2 h after creation (InPerson) or 48 h (Remote). ExpiresAt is set at creation.</summary>
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
|
||||
@@ -40,26 +40,33 @@ public class Quote : BaseEntity
|
||||
public DateTime? ApprovedDate { get; set; }
|
||||
|
||||
// Pricing — all values are snapshots captured at save time and must not be recalculated on load
|
||||
public decimal MaterialCosts { get; set; } // Sum of powder/material costs across all items
|
||||
public decimal LaborCosts { get; set; } // Sum of labor costs across all items
|
||||
public decimal EquipmentCosts { get; set; } // Sum of equipment costs across all items
|
||||
public decimal ItemsSubtotal { get; set; } // Sum of item prices before any quote-level costs
|
||||
public decimal OvenBatchCost { get; set; } // Oven batch charge applied at quote level
|
||||
public decimal ShopSuppliesAmount { get; set; } // Shop supplies dollar amount
|
||||
public decimal ShopSuppliesPercent { get; set; } // Shop supplies percentage used
|
||||
public decimal OverheadAmount { get; set; } // Overhead dollar amount
|
||||
public decimal OverheadPercent { get; set; } // Overhead percentage used
|
||||
public decimal ProfitMargin { get; set; } // Profit margin dollar amount
|
||||
public decimal ProfitPercent { get; set; } // Profit margin percentage used
|
||||
public decimal SubTotal { get; set; } // SubtotalBeforeDiscount (items + oven + overhead + profit + shop supplies)
|
||||
public decimal MaterialCosts { get; set; } // Sum of powder/material costs across all items
|
||||
public decimal LaborCosts { get; set; } // Sum of labor costs across all items
|
||||
public decimal EquipmentCosts { get; set; } // Sum of equipment costs across all items
|
||||
public decimal ItemsSubtotal { get; set; } // Sum of item prices before any quote-level costs
|
||||
public decimal OvenBatchCost { get; set; } // Oven batch charge applied at quote level
|
||||
public decimal FacilityOverheadCost { get; set; } // Rent + utilities apportioned by estimated job hours
|
||||
public decimal FacilityOverheadRatePerHour { get; set; }// Rate used for facility overhead ($/hr)
|
||||
public decimal ShopSuppliesAmount { get; set; } // Shop supplies dollar amount
|
||||
public decimal ShopSuppliesPercent { get; set; } // Shop supplies percentage used
|
||||
public decimal OverheadAmount { get; set; } // Legacy overhead (now always 0; kept for migration safety)
|
||||
public decimal OverheadPercent { get; set; } // Legacy overhead percent
|
||||
public decimal ProfitMargin { get; set; } // Profit margin dollar amount (0 — baked into item prices)
|
||||
public decimal ProfitPercent { get; set; } // Markup % used (for display reference)
|
||||
public decimal SubTotal { get; set; } // SubtotalBeforeDiscount (items + oven + facility overhead + shop supplies)
|
||||
|
||||
// Discount Information
|
||||
public DiscountType DiscountType { get; set; } = DiscountType.None;
|
||||
public decimal DiscountValue { get; set; } = 0; // Value entered by user (percentage or fixed amount)
|
||||
public decimal DiscountPercent { get; set; } // Calculated: actual percentage applied
|
||||
public decimal DiscountAmount { get; set; } // Calculated: actual dollar amount deducted
|
||||
public string? DiscountReason { get; set; } // Why discount was applied
|
||||
public decimal DiscountValue { get; set; } = 0; // Value entered by user (percentage or fixed amount)
|
||||
public decimal PricingTierDiscountAmount { get; set; } // Discount from customer's pricing tier
|
||||
public decimal PricingTierDiscountPercent { get; set; } // Tier discount percentage
|
||||
public decimal QuoteDiscountAmount { get; set; } // Manual quote-level discount amount
|
||||
public decimal QuoteDiscountPercent { get; set; } // Manual quote-level discount percentage
|
||||
public decimal DiscountPercent { get; set; } // Combined: actual percentage applied
|
||||
public decimal DiscountAmount { get; set; } // Combined: actual dollar amount deducted
|
||||
public string? DiscountReason { get; set; } // Why discount was applied
|
||||
public bool HideDiscountFromCustomer { get; set; } = false; // Show only total on PDFs/portal
|
||||
public decimal SubtotalAfterDiscount { get; set; } // SubTotal minus all discounts, before rush/tax
|
||||
|
||||
public decimal TaxPercent { get; set; }
|
||||
public decimal TaxAmount { get; set; }
|
||||
|
||||
@@ -31,10 +31,13 @@ public interface ICompanyListService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a paged, searched, and sorted slice of non-deleted companies together with the
|
||||
/// total unfiltered count for pagination.
|
||||
/// total count for pagination and the count of churned accounts that are currently hidden.
|
||||
/// When <paramref name="hideChurned"/> is true, Expired/Canceled companies whose subscription
|
||||
/// ended more than 14 days ago are excluded from results (but still counted for the banner).
|
||||
/// </summary>
|
||||
Task<(List<Company> Companies, int TotalCount)> GetPagedAsync(
|
||||
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize);
|
||||
Task<(List<Company> Companies, int TotalCount, int ChurnedCount)> GetPagedAsync(
|
||||
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize,
|
||||
bool hideChurned = true);
|
||||
|
||||
/// <summary>
|
||||
/// Returns job, quote, customer, and wizard completion counts for each of the supplied
|
||||
|
||||
Generated
+10742
File diff suppressed because it is too large
Load Diff
+82
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddKioskIntakeOutputSetting : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "LinkedQuoteId",
|
||||
table: "KioskSessions",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "KioskIntakeOutput",
|
||||
table: "CompanyPreferences",
|
||||
type: "nvarchar(max)",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LinkedQuoteId",
|
||||
table: "KioskSessions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "KioskIntakeOutput",
|
||||
table: "CompanyPreferences");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4259));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4264));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4266));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10748
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddJobOvenBatchFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "OvenBatches",
|
||||
table: "Jobs",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "OvenCycleMinutes",
|
||||
table: "Jobs",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6420));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6425));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6426));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "OvenBatches",
|
||||
table: "Jobs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "OvenCycleMinutes",
|
||||
table: "Jobs");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2349));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2366));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2367));
|
||||
}
|
||||
}
|
||||
}
|
||||
+10751
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddJobItemIsAiItem : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsAiItem",
|
||||
table: "JobItems",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7475));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7481));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7482));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsAiItem",
|
||||
table: "JobItems");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6420));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6425));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6426));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10754
File diff suppressed because it is too large
Load Diff
+71
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddGiftCertificateBatchId : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "BatchId",
|
||||
table: "GiftCertificates",
|
||||
type: "uniqueidentifier",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 0, 30, 26, 297, DateTimeKind.Utc).AddTicks(7656));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 0, 30, 26, 297, DateTimeKind.Utc).AddTicks(7662));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 0, 30, 26, 297, DateTimeKind.Utc).AddTicks(7664));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BatchId",
|
||||
table: "GiftCertificates");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7475));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7481));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7482));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10757
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddJobPricingSnapshot : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PricingBreakdownJson",
|
||||
table: "Jobs",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4618));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4623));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4625));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PricingBreakdownJson",
|
||||
table: "Jobs");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2464));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2473));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2474));
|
||||
}
|
||||
}
|
||||
}
|
||||
src/PowderCoating.Infrastructure/Migrations/20260515194344_AddQuotePricingSnapshotFields.Designer.cs
Generated
+10778
File diff suppressed because it is too large
Load Diff
+138
@@ -0,0 +1,138 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddQuotePricingSnapshotFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "FacilityOverheadCost",
|
||||
table: "Quotes",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "FacilityOverheadRatePerHour",
|
||||
table: "Quotes",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "PricingTierDiscountAmount",
|
||||
table: "Quotes",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "PricingTierDiscountPercent",
|
||||
table: "Quotes",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "QuoteDiscountAmount",
|
||||
table: "Quotes",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "QuoteDiscountPercent",
|
||||
table: "Quotes",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "SubtotalAfterDiscount",
|
||||
table: "Quotes",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(845));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(850));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(852));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FacilityOverheadCost",
|
||||
table: "Quotes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FacilityOverheadRatePerHour",
|
||||
table: "Quotes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PricingTierDiscountAmount",
|
||||
table: "Quotes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PricingTierDiscountPercent",
|
||||
table: "Quotes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "QuoteDiscountAmount",
|
||||
table: "Quotes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "QuoteDiscountPercent",
|
||||
table: "Quotes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SubtotalAfterDiscount",
|
||||
table: "Quotes");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4618));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4623));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4625));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2253,6 +2253,10 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<int>("JobRetentionYears")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("KioskIntakeOutput")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("LogRetentionDays")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -3286,6 +3290,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<Guid?>("BatchId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("CertificateCode")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(450)");
|
||||
@@ -4201,9 +4208,18 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<decimal>("OvenBatchCost")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int>("OvenBatches")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int?>("OvenCostId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int?>("OvenCycleMinutes")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("PricingBreakdownJson")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("QuoteId")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -4472,6 +4488,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<bool>("IncludePrepCost")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsAiItem")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
@@ -5637,6 +5656,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<int?>("LinkedJobId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int?>("LinkedQuoteId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("RemoteLinkEmail")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -6692,7 +6714,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4259),
|
||||
CreatedAt = new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(845),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -6703,7 +6725,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4264),
|
||||
CreatedAt = new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(850),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -6714,7 +6736,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4266),
|
||||
CreatedAt = new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(852),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
@@ -6961,6 +6983,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<DateTime?>("ExpirationDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<decimal>("FacilityOverheadCost")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("FacilityOverheadRatePerHour")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<bool>("HideDiscountFromCustomer")
|
||||
.HasColumnType("bit");
|
||||
|
||||
@@ -7006,6 +7034,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("PreparedById")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<decimal>("PricingTierDiscountAmount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("PricingTierDiscountPercent")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("ProfitMargin")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
@@ -7045,6 +7079,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<DateTime>("QuoteDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<decimal>("QuoteDiscountAmount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("QuoteDiscountPercent")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("QuoteNumber")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(450)");
|
||||
@@ -7070,6 +7110,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<decimal>("SubTotal")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("SubtotalAfterDiscount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("Tags")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces.Services;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
@@ -21,15 +22,34 @@ public class CompanyListService : ICompanyListService
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<(List<Company> Companies, int TotalCount)> GetPagedAsync(
|
||||
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize)
|
||||
public async Task<(List<Company> Companies, int TotalCount, int ChurnedCount)> GetPagedAsync(
|
||||
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize,
|
||||
bool hideChurned = true)
|
||||
{
|
||||
var cutoff = DateTime.UtcNow.AddDays(-14);
|
||||
|
||||
// Always count churned regardless of hideChurned so the banner can show a number.
|
||||
var churnedCount = await _context.Companies
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(c => !c.IsDeleted
|
||||
&& (c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled)
|
||||
&& c.SubscriptionEndDate != null
|
||||
&& c.SubscriptionEndDate < cutoff)
|
||||
.CountAsync();
|
||||
|
||||
var query = _context.Companies
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(c => !c.IsDeleted)
|
||||
.AsQueryable();
|
||||
|
||||
if (hideChurned)
|
||||
query = query.Where(c =>
|
||||
!((c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled)
|
||||
&& c.SubscriptionEndDate != null
|
||||
&& c.SubscriptionEndDate < cutoff));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
{
|
||||
var s = searchTerm.ToLower();
|
||||
@@ -61,7 +81,7 @@ public class CompanyListService : ICompanyListService
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
return (companies, totalCount);
|
||||
return (companies, totalCount, churnedCount);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
||||
@@ -66,15 +66,16 @@ public class CompaniesController : Controller
|
||||
string sortColumn = "CompanyName",
|
||||
string sortDirection = "asc",
|
||||
int pageNumber = 1,
|
||||
int pageSize = 25)
|
||||
int pageSize = 25,
|
||||
bool showChurned = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
pageNumber = Math.Max(1, pageNumber);
|
||||
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
|
||||
|
||||
var (companies, totalCount) = await _companyList.GetPagedAsync(
|
||||
searchTerm, sortColumn, sortDirection, pageNumber, pageSize);
|
||||
var (companies, totalCount, churnedCount) = await _companyList.GetPagedAsync(
|
||||
searchTerm, sortColumn, sortDirection, pageNumber, pageSize, hideChurned: !showChurned);
|
||||
|
||||
var companyDtos = _mapper.Map<List<CompanyListDto>>(companies);
|
||||
|
||||
@@ -128,6 +129,8 @@ public class CompaniesController : Controller
|
||||
ViewBag.PageSize = pageSize;
|
||||
ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
||||
ViewBag.ImpersonatingCompanyId = HttpContext.Session.GetInt32("ImpersonatingCompanyId");
|
||||
ViewBag.ShowChurned = showChurned;
|
||||
ViewBag.ChurnedCount = churnedCount;
|
||||
|
||||
return View(companyDtos);
|
||||
}
|
||||
|
||||
@@ -45,18 +45,30 @@ public class CompanyHealthController : Controller
|
||||
/// user's risk/search filters, so the KPI cards always show platform-wide totals.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Index(string? risk, string? search, bool configIssuesOnly = false)
|
||||
public async Task<IActionResult> Index(string? risk, string? search, bool configIssuesOnly = false, bool showChurned = false)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var d30 = now.AddDays(-30);
|
||||
var d90 = now.AddDays(-90);
|
||||
var churnedCutoff = now.AddDays(-14);
|
||||
|
||||
// One query per signal — all keyed by CompanyId
|
||||
var companies = await _db.Companies
|
||||
var allCompanies = await _db.Companies
|
||||
.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(c => !c.IsDeleted)
|
||||
.ToListAsync();
|
||||
|
||||
var churnedCount = allCompanies.Count(c =>
|
||||
(c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled)
|
||||
&& c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate.Value < churnedCutoff);
|
||||
|
||||
var companies = showChurned
|
||||
? allCompanies
|
||||
: allCompanies.Where(c =>
|
||||
!((c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled)
|
||||
&& c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate.Value < churnedCutoff))
|
||||
.ToList();
|
||||
|
||||
var lastLogins = await _db.Users
|
||||
.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(u => u.LastLoginDate != null)
|
||||
@@ -163,6 +175,8 @@ public class CompanyHealthController : Controller
|
||||
ViewBag.Risk = risk;
|
||||
ViewBag.Search = search;
|
||||
ViewBag.ConfigIssuesOnly = configIssuesOnly;
|
||||
ViewBag.ShowChurned = showChurned;
|
||||
ViewBag.ChurnedCount = churnedCount;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
all = all.Where(h =>
|
||||
|
||||
@@ -543,6 +543,15 @@ public class CompanySettingsController : Controller
|
||||
public Task<IActionResult> UpdateWorkOrderTemplate([FromBody] UpdateWorkOrderTemplateDto dto) =>
|
||||
UpdatePreferences(dto, "Work order settings saved successfully.");
|
||||
|
||||
/// <summary>
|
||||
/// Saves kiosk intake output preference ("Quote" or "Job") to <see cref="CompanyPreferences"/>.
|
||||
/// Delegates to <see cref="UpdatePreferences{TDto}"/>.
|
||||
/// </summary>
|
||||
// POST: CompanySettings/UpdateKioskSettings
|
||||
[HttpPost]
|
||||
public Task<IActionResult> UpdateKioskSettings([FromBody] UpdateKioskSettingsDto dto) =>
|
||||
UpdatePreferences(dto, "Kiosk settings saved successfully.");
|
||||
|
||||
/// <summary>
|
||||
/// Persists the company's pricing model parameters — labor rates, sandblasting/masking multipliers,
|
||||
/// oven cost per hour, overhead admin/facility percentages, profit margin, and default tax rate —
|
||||
|
||||
@@ -107,7 +107,8 @@ public class GiftCertificatesController : Controller
|
||||
IssuedReason = gc.IssuedReason,
|
||||
Status = gc.Status,
|
||||
IssueDate = gc.IssueDate,
|
||||
ExpiryDate = gc.ExpiryDate
|
||||
ExpiryDate = gc.ExpiryDate,
|
||||
BatchId = gc.BatchId
|
||||
})
|
||||
.ToList();
|
||||
|
||||
@@ -440,6 +441,183 @@ public class GiftCertificatesController : Controller
|
||||
return acct?.Id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows the bulk certificate creation form. Defaults to Promotional reason and 25 certificates
|
||||
/// since the primary use case is car shows and events where a batch of same-value certificates
|
||||
/// is distributed to attendees.
|
||||
/// </summary>
|
||||
public IActionResult BulkCreate()
|
||||
{
|
||||
return View(new BulkCreateGiftCertificateDto());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates N gift certificates in a single batch, records GL entries for each, then redirects
|
||||
/// to a confirmation page where the user can download the full batch as a single print-ready PDF.
|
||||
/// Certificate codes are generated sequentially so the batch occupies a contiguous range (e.g.
|
||||
/// GC-2506-0012 through GC-2506-0036), making it easy to audit which codes belong to each event.
|
||||
/// GL treatment mirrors single-certificate issuance: Sold certs debit Checking, all others debit
|
||||
/// Sales Discounts (4950) and credit GC Liability (2500).
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> BulkCreate(BulkCreateGiftCertificateDto dto)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return View(dto);
|
||||
|
||||
try
|
||||
{
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
var companyId = currentUser?.CompanyId ?? 0;
|
||||
|
||||
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(companyId);
|
||||
int? checkingAcctId = null;
|
||||
int? discountAcctId = null;
|
||||
|
||||
if (dto.IssuedReason == GiftCertificateIssuedReason.Sold)
|
||||
{
|
||||
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.IsActive && (a.AccountSubType == AccountSubTypeEnum.Checking
|
||||
|| a.AccountSubType == AccountSubTypeEnum.Cash));
|
||||
checkingAcctId = acct?.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.IsActive && a.AccountNumber == "4950");
|
||||
discountAcctId = acct?.Id;
|
||||
}
|
||||
|
||||
var batchId = Guid.NewGuid();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < dto.Quantity; i++)
|
||||
{
|
||||
var code = await GenerateCertificateCodeAsync(companyId);
|
||||
|
||||
var cert = new GiftCertificate
|
||||
{
|
||||
CertificateCode = code,
|
||||
OriginalAmount = dto.Amount,
|
||||
RedeemedAmount = 0,
|
||||
IssuedReason = dto.IssuedReason,
|
||||
Status = GiftCertificateStatus.Active,
|
||||
IssueDate = now,
|
||||
ExpiryDate = dto.ExpiryDate,
|
||||
Notes = dto.Notes,
|
||||
IssuedById = currentUser?.Id,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = now,
|
||||
CreatedBy = currentUser?.Email,
|
||||
BatchId = batchId
|
||||
};
|
||||
|
||||
await _unitOfWork.GiftCertificates.AddAsync(cert);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
await _accountBalanceService.CreditAsync(gcLiabilityAcctId, cert.OriginalAmount);
|
||||
if (dto.IssuedReason == GiftCertificateIssuedReason.Sold)
|
||||
await _accountBalanceService.DebitAsync(checkingAcctId, cert.OriginalAmount);
|
||||
else
|
||||
await _accountBalanceService.DebitAsync(discountAcctId, cert.OriginalAmount);
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(BulkResult), new { batchId });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error creating bulk gift certificates");
|
||||
this.ToastError("An error occurred creating the certificates.");
|
||||
return View(dto);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Displays the batch confirmation page. Driven by BatchId so it is bookmarkable and survives
|
||||
/// browser back/refresh — the user can return here any time to re-download the batch PDF.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> BulkResult(Guid batchId)
|
||||
{
|
||||
if (batchId == Guid.Empty)
|
||||
return RedirectToAction(nameof(Index));
|
||||
|
||||
var certs = await _unitOfWork.GiftCertificates.FindAsync(
|
||||
gc => gc.BatchId == batchId, false);
|
||||
|
||||
if (!certs.Any())
|
||||
return RedirectToAction(nameof(Index));
|
||||
|
||||
return View(certs.OrderBy(c => c.CertificateCode).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Streams a multi-page PDF for an entire batch identified by BatchId. GET endpoint so the
|
||||
/// user can bookmark or re-open it at any time after the batch was originally created.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> BatchDownloadPdf(Guid batchId)
|
||||
{
|
||||
if (batchId == Guid.Empty)
|
||||
return BadRequest();
|
||||
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
var companyId = currentUser?.CompanyId ?? 0;
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
||||
|
||||
var companyInfo = new Application.DTOs.Company.CompanyInfoDto
|
||||
{
|
||||
CompanyName = company?.CompanyName ?? string.Empty,
|
||||
Phone = company?.Phone,
|
||||
Address = company?.Address,
|
||||
City = company?.City,
|
||||
State = company?.State,
|
||||
ZipCode = company?.ZipCode,
|
||||
PrimaryContactEmail = company?.PrimaryContactEmail
|
||||
};
|
||||
|
||||
var certs = await _unitOfWork.GiftCertificates.FindAsync(
|
||||
gc => gc.BatchId == batchId, false,
|
||||
gc => gc.RecipientCustomer);
|
||||
|
||||
if (!certs.Any())
|
||||
return NotFound();
|
||||
|
||||
var dtos = certs.OrderBy(c => c.CertificateCode).Select(cert => new GiftCertificateDto
|
||||
{
|
||||
Id = cert.Id,
|
||||
CertificateCode = cert.CertificateCode,
|
||||
OriginalAmount = cert.OriginalAmount,
|
||||
RedeemedAmount = cert.RedeemedAmount,
|
||||
RemainingBalance = cert.RemainingBalance,
|
||||
RecipientName = cert.RecipientCustomer != null
|
||||
? (cert.RecipientCustomer.CompanyName ?? $"{cert.RecipientCustomer.ContactFirstName} {cert.RecipientCustomer.ContactLastName}".Trim())
|
||||
: cert.RecipientName,
|
||||
RecipientEmail = cert.RecipientEmail,
|
||||
IssuedReason = cert.IssuedReason,
|
||||
Status = cert.Status,
|
||||
IssueDate = cert.IssueDate,
|
||||
ExpiryDate = cert.ExpiryDate,
|
||||
Notes = cert.Notes
|
||||
}).ToList();
|
||||
|
||||
try
|
||||
{
|
||||
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
|
||||
var pdfBytes = await _pdfService.GenerateBulkGiftCertificatePdfAsync(dtos, logoData, logoContentType, companyInfo);
|
||||
var first = dtos.First().CertificateCode;
|
||||
var last = dtos.Last().CertificateCode;
|
||||
var fileName = dtos.Count == 1
|
||||
? $"GiftCertificate-{first}.pdf"
|
||||
: $"GiftCertificates-{first}-to-{last}.pdf";
|
||||
return File(pdfBytes, "application/pdf", fileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error generating batch gift certificate PDF for batch {BatchId}", batchId);
|
||||
TempData["Error"] = "Could not generate PDF.";
|
||||
return RedirectToAction(nameof(BulkResult), new { batchId });
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company)
|
||||
{
|
||||
if (company == null) return (null, null);
|
||||
|
||||
@@ -125,5 +125,13 @@ namespace PowderCoating.Web.Controllers
|
||||
{
|
||||
return View();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serves the Customer Intake Kiosk help article explaining the tablet kiosk setup, the staff-triggered intake flow, and the Intakes review page.
|
||||
/// </summary>
|
||||
public IActionResult CustomerIntakeKiosk()
|
||||
{
|
||||
return View();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,6 +304,32 @@ public class InventoryController : Controller
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Contribute/sync to the platform powder catalog if we have enough identity data.
|
||||
// Runs silently — a failure here never blocks the inventory save.
|
||||
if (!string.IsNullOrWhiteSpace(dto.Manufacturer) && !string.IsNullOrWhiteSpace(dto.ManufacturerPartNumber))
|
||||
{
|
||||
var catalogResult = new InventoryAiLookupResult
|
||||
{
|
||||
Manufacturer = dto.Manufacturer,
|
||||
ManufacturerPartNumber = dto.ManufacturerPartNumber,
|
||||
ColorName = dto.ColorName ?? item.Name,
|
||||
Finish = dto.Finish,
|
||||
CureTemperatureF = dto.CureTemperatureF,
|
||||
CureTimeMinutes = dto.CureTimeMinutes,
|
||||
ColorFamilies = dto.ColorFamilies,
|
||||
RequiresClearCoat = dto.RequiresClearCoat ? true : (bool?)null,
|
||||
CoverageSqFtPerLb = dto.CoverageSqFtPerLb,
|
||||
SpecificGravity = dto.SpecificGravity,
|
||||
TransferEfficiency = dto.TransferEfficiency,
|
||||
UnitCostPerLb = dto.UnitCost > 0 ? dto.UnitCost : null,
|
||||
SpecPageUrl = dto.SpecPageUrl,
|
||||
ImageUrl = dto.ImageUrl,
|
||||
SdsUrl = dto.SdsUrl,
|
||||
TdsUrl = dto.TdsUrl,
|
||||
};
|
||||
await EnrichFromCatalogAsync(catalogResult, autoContribute: true);
|
||||
}
|
||||
|
||||
TempData["Success"] = "Inventory item created successfully.";
|
||||
return RedirectToAction(nameof(Details), new { id = item.Id });
|
||||
}
|
||||
@@ -704,6 +730,8 @@ public class InventoryController : Controller
|
||||
return Json(new { success = false, errorMessage = "No product URL provided." });
|
||||
|
||||
var result = await _aiLookupService.LookupByUrlAsync(productUrl, colorName);
|
||||
if (result.Success)
|
||||
await EnrichFromCatalogAsync(result, autoContribute: true);
|
||||
return Json(result);
|
||||
}
|
||||
|
||||
@@ -750,6 +778,39 @@ public class InventoryController : Controller
|
||||
result.SdsUrl ??= match.SdsUrl;
|
||||
result.TdsUrl ??= match.TdsUrl;
|
||||
if (match.UnitPrice > 0) result.UnitCostPerLb ??= match.UnitPrice;
|
||||
|
||||
// Back-sync: fill NULL catalog fields from the incoming result so the catalog
|
||||
// gets richer over time without overwriting anything already stored.
|
||||
bool catalogDirty = false;
|
||||
if (match.Finish == null && !string.IsNullOrWhiteSpace(result.Finish)) { match.Finish = result.Finish; catalogDirty = true; }
|
||||
if (match.CureTemperatureF == null && result.CureTemperatureF != null) { match.CureTemperatureF = result.CureTemperatureF; catalogDirty = true; }
|
||||
if (match.CureTimeMinutes == null && result.CureTimeMinutes != null) { match.CureTimeMinutes = result.CureTimeMinutes; catalogDirty = true; }
|
||||
if (match.ColorFamilies == null && !string.IsNullOrWhiteSpace(result.ColorFamilies)){ match.ColorFamilies = result.ColorFamilies; catalogDirty = true; }
|
||||
if (match.RequiresClearCoat == null && result.RequiresClearCoat != null) { match.RequiresClearCoat = result.RequiresClearCoat; catalogDirty = true; }
|
||||
if (match.CoverageSqFtPerLb == null && result.CoverageSqFtPerLb != null) { match.CoverageSqFtPerLb = result.CoverageSqFtPerLb; catalogDirty = true; }
|
||||
if (match.SpecificGravity == null && result.SpecificGravity != null) { match.SpecificGravity = result.SpecificGravity; catalogDirty = true; }
|
||||
if (match.TransferEfficiency == null && result.TransferEfficiency != null) { match.TransferEfficiency = result.TransferEfficiency; catalogDirty = true; }
|
||||
if (string.IsNullOrWhiteSpace(match.ImageUrl) && !string.IsNullOrWhiteSpace(result.ImageUrl)) { match.ImageUrl = result.ImageUrl; catalogDirty = true; }
|
||||
if (string.IsNullOrWhiteSpace(match.ProductUrl) && !string.IsNullOrWhiteSpace(result.SpecPageUrl)){ match.ProductUrl = result.SpecPageUrl; catalogDirty = true; }
|
||||
if (string.IsNullOrWhiteSpace(match.SdsUrl) && !string.IsNullOrWhiteSpace(result.SdsUrl)) { match.SdsUrl = result.SdsUrl; catalogDirty = true; }
|
||||
if (string.IsNullOrWhiteSpace(match.TdsUrl) && !string.IsNullOrWhiteSpace(result.TdsUrl)) { match.TdsUrl = result.TdsUrl; catalogDirty = true; }
|
||||
if (match.UnitPrice == 0 && (result.UnitCostPerLb ?? 0) > 0) { match.UnitPrice = result.UnitCostPerLb!.Value; catalogDirty = true; }
|
||||
|
||||
if (catalogDirty)
|
||||
{
|
||||
match.UpdatedAt = DateTime.UtcNow;
|
||||
try
|
||||
{
|
||||
await _unitOfWork.PowderCatalog.UpdateAsync(match);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
_logger.LogInformation("Back-synced catalog gaps for {VendorName} {Sku}", match.VendorName, match.Sku);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to back-sync catalog entry {Id}", match.Id);
|
||||
}
|
||||
}
|
||||
|
||||
return (true, false);
|
||||
}
|
||||
|
||||
@@ -767,6 +828,7 @@ public class InventoryController : Controller
|
||||
VendorName = manufacturer,
|
||||
Sku = sku,
|
||||
ColorName = colorName,
|
||||
UnitPrice = result.UnitCostPerLb ?? 0m,
|
||||
CureTemperatureF = result.CureTemperatureF,
|
||||
CureTimeMinutes = result.CureTimeMinutes,
|
||||
Finish = result.Finish,
|
||||
@@ -1050,61 +1112,50 @@ public class InventoryController : Controller
|
||||
.Select(i => i.ManufacturerPartNumber!.Trim().ToLower())
|
||||
.ToHashSet();
|
||||
|
||||
// When a vendor is specified, search vendor-scoped first. Only widen to all vendors
|
||||
// if the scoped search returns nothing — prevents a cross-vendor color match from
|
||||
// being returned as the only result when the user clearly intended a specific manufacturer.
|
||||
IEnumerable<PowderCatalogItem> matches;
|
||||
if (!string.IsNullOrEmpty(vendorTerm))
|
||||
{
|
||||
matches = await _unitOfWork.PowderCatalog.FindAsync(p =>
|
||||
p.VendorName.ToLower().Contains(vendorTerm) && (
|
||||
p.Sku.ToLower() == term ||
|
||||
p.ColorName.ToLower().Contains(term) ||
|
||||
p.Sku.ToLower().Contains(term)));
|
||||
|
||||
// Fall back to all vendors only when the scoped search finds nothing
|
||||
if (!matches.Any())
|
||||
{
|
||||
matches = await _unitOfWork.PowderCatalog.FindAsync(p =>
|
||||
p.Sku.ToLower() == term ||
|
||||
p.ColorName.ToLower().Contains(term) ||
|
||||
p.Sku.ToLower().Contains(term));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
matches = await _unitOfWork.PowderCatalog.FindAsync(p =>
|
||||
p.Sku.ToLower() == term ||
|
||||
p.ColorName.ToLower().Contains(term) ||
|
||||
p.Sku.ToLower().Contains(term));
|
||||
}
|
||||
// Single query — all partial color/SKU matches across all vendors.
|
||||
// Results are ranked: exact vendor + exact color (isExact=true) sorts first and
|
||||
// triggers auto-fill in the JS. Everything else goes to the picker modal.
|
||||
// This means a user who typed "Columbia Coatings" + "Lime Green" gets auto-fill
|
||||
// only when that exact product is in the catalog; otherwise they see a ranked modal
|
||||
// with same-vendor results at the top and a "Not Listed — Search Online" escape hatch.
|
||||
var matches = await _unitOfWork.PowderCatalog.FindAsync(p =>
|
||||
p.ColorName.ToLower().Contains(term) ||
|
||||
p.Sku.ToLower() == term ||
|
||||
p.Sku.ToLower().Contains(term));
|
||||
|
||||
var results = matches
|
||||
.Where(p => !existingSkus.Contains(p.Sku.ToLower()))
|
||||
.OrderBy(p => p.Sku.ToLower() == term ? 0 : 1)
|
||||
.ThenBy(p => p.ColorName)
|
||||
.Select(p => new
|
||||
.Select(p =>
|
||||
{
|
||||
id = p.Id,
|
||||
vendorName = p.VendorName,
|
||||
sku = p.Sku,
|
||||
colorName = p.ColorName,
|
||||
description = p.Description,
|
||||
unitPrice = p.UnitPrice,
|
||||
imageUrl = p.ImageUrl,
|
||||
sdsUrl = p.SdsUrl,
|
||||
tdsUrl = p.TdsUrl,
|
||||
applicationGuideUrl = p.ApplicationGuideUrl,
|
||||
productUrl = p.ProductUrl,
|
||||
isDiscontinued = p.IsDiscontinued,
|
||||
cureTemperatureF = p.CureTemperatureF,
|
||||
cureTimeMinutes = p.CureTimeMinutes,
|
||||
finish = p.Finish,
|
||||
colorFamilies = p.ColorFamilies,
|
||||
requiresClearCoat = p.RequiresClearCoat,
|
||||
coverageSqFtPerLb = p.CoverageSqFtPerLb,
|
||||
specificGravity = p.SpecificGravity,
|
||||
transferEfficiency = GetEffectiveTransferEfficiency(p.TransferEfficiency)
|
||||
var vendorMatch = string.IsNullOrEmpty(vendorTerm) || p.VendorName.ToLower().Contains(vendorTerm);
|
||||
var colorExact = p.ColorName.ToLower() == term;
|
||||
return (p, isExact: vendorMatch && colorExact, vendorMatch, colorExact);
|
||||
})
|
||||
.OrderBy(x => x.isExact ? 0 : x.vendorMatch ? 1 : x.colorExact ? 2 : 3)
|
||||
.ThenBy(x => x.p.ColorName)
|
||||
.Select(x => new
|
||||
{
|
||||
id = x.p.Id,
|
||||
vendorName = x.p.VendorName,
|
||||
sku = x.p.Sku,
|
||||
colorName = x.p.ColorName,
|
||||
description = x.p.Description,
|
||||
unitPrice = x.p.UnitPrice,
|
||||
imageUrl = x.p.ImageUrl,
|
||||
sdsUrl = x.p.SdsUrl,
|
||||
tdsUrl = x.p.TdsUrl,
|
||||
applicationGuideUrl = x.p.ApplicationGuideUrl,
|
||||
productUrl = x.p.ProductUrl,
|
||||
isDiscontinued = x.p.IsDiscontinued,
|
||||
isExact = x.isExact,
|
||||
cureTemperatureF = x.p.CureTemperatureF,
|
||||
cureTimeMinutes = x.p.CureTimeMinutes,
|
||||
finish = x.p.Finish,
|
||||
colorFamilies = x.p.ColorFamilies,
|
||||
requiresClearCoat = x.p.RequiresClearCoat,
|
||||
coverageSqFtPerLb = x.p.CoverageSqFtPerLb,
|
||||
specificGravity = x.p.SpecificGravity,
|
||||
transferEfficiency = GetEffectiveTransferEfficiency(x.p.TransferEfficiency)
|
||||
})
|
||||
.ToList();
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.Text.Json;
|
||||
using AutoMapper;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PowderCoating.Application.DTOs.Common;
|
||||
using PowderCoating.Application.DTOs.Invoice;
|
||||
using PowderCoating.Application.DTOs.Quote;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
@@ -396,13 +398,15 @@ public class InvoicesController : Controller
|
||||
|
||||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||
{
|
||||
SourceJobItemId = item.Id,
|
||||
Description = item.Description ?? "Powder Coating",
|
||||
Quantity = 1,
|
||||
UnitPrice = item.TotalPrice,
|
||||
TotalPrice = item.TotalPrice,
|
||||
ColorName = item.ColorName,
|
||||
DisplayOrder = order++,
|
||||
SourceJobItemId = item.Id,
|
||||
CatalogItemId = item.CatalogItemId,
|
||||
Description = item.Description ?? "Powder Coating",
|
||||
Quantity = item.Quantity > 0 ? item.Quantity : 1,
|
||||
UnitPrice = item.UnitPrice,
|
||||
TotalPrice = item.TotalPrice,
|
||||
ColorName = item.ColorName,
|
||||
Notes = item.Notes,
|
||||
DisplayOrder = order++,
|
||||
RevenueAccountId = revenueAccountId
|
||||
});
|
||||
}
|
||||
@@ -437,7 +441,10 @@ public class InvoicesController : Controller
|
||||
// because FinalPrice is recalculated on every item edit and can drift from the original quote.
|
||||
if (sourceQuote != null)
|
||||
{
|
||||
// Bundle all quote-level charges so the invoice subtotal matches the quote total.
|
||||
// FacilityOverheadCost is included — it is a real cost baked into the quoted price.
|
||||
var processingFees = sourceQuote.OvenBatchCost
|
||||
+ sourceQuote.FacilityOverheadCost
|
||||
+ sourceQuote.ShopSuppliesAmount
|
||||
+ sourceQuote.RushFee;
|
||||
|
||||
@@ -460,15 +467,17 @@ public class InvoicesController : Controller
|
||||
}
|
||||
else if (hadJobItems)
|
||||
{
|
||||
// Direct job — no source quote. Use the stored job-level fees rather than
|
||||
// recalculating, so the invoice always matches the total shown on the job page.
|
||||
// OvenBatchCost and ShopSuppliesAmount are saved by the pricing engine (with
|
||||
// OvenCostId) when job items are created or updated.
|
||||
// Direct job — no source quote. Read all charges from the pricing snapshot so the
|
||||
// invoice always matches the total shown on the job's Pricing Summary card.
|
||||
QuotePricingBreakdownDto? jobBreakdown = null;
|
||||
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
|
||||
jobBreakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
|
||||
|
||||
if (job.OvenBatchCost > 0.01m)
|
||||
{
|
||||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||
{
|
||||
Description = $"Oven Processing Fee",
|
||||
Description = "Oven Processing Fee",
|
||||
Quantity = 1,
|
||||
UnitPrice = Math.Round(job.OvenBatchCost, 2),
|
||||
TotalPrice = Math.Round(job.OvenBatchCost, 2),
|
||||
@@ -477,6 +486,20 @@ public class InvoicesController : Controller
|
||||
});
|
||||
}
|
||||
|
||||
var facilityOverhead = jobBreakdown?.FacilityOverheadCost ?? 0m;
|
||||
if (facilityOverhead > 0.01m)
|
||||
{
|
||||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||
{
|
||||
Description = "Facility Overhead",
|
||||
Quantity = 1,
|
||||
UnitPrice = Math.Round(facilityOverhead, 2),
|
||||
TotalPrice = Math.Round(facilityOverhead, 2),
|
||||
DisplayOrder = order++,
|
||||
RevenueAccountId = defaultRevenueAccount?.Id
|
||||
});
|
||||
}
|
||||
|
||||
if (job.ShopSuppliesAmount > 0.01m)
|
||||
{
|
||||
var suppliesDesc = job.ShopSuppliesPercent > 0
|
||||
@@ -488,6 +511,20 @@ public class InvoicesController : Controller
|
||||
Quantity = 1,
|
||||
UnitPrice = Math.Round(job.ShopSuppliesAmount, 2),
|
||||
TotalPrice = Math.Round(job.ShopSuppliesAmount, 2),
|
||||
DisplayOrder = order++,
|
||||
RevenueAccountId = defaultRevenueAccount?.Id
|
||||
});
|
||||
}
|
||||
|
||||
var rushFee = jobBreakdown?.RushFee ?? 0m;
|
||||
if (rushFee > 0.01m)
|
||||
{
|
||||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||
{
|
||||
Description = "Rush Fee",
|
||||
Quantity = 1,
|
||||
UnitPrice = Math.Round(rushFee, 2),
|
||||
TotalPrice = Math.Round(rushFee, 2),
|
||||
DisplayOrder = order,
|
||||
RevenueAccountId = defaultRevenueAccount?.Id
|
||||
});
|
||||
|
||||
@@ -422,72 +422,24 @@ public class JobsController : Controller
|
||||
// Populate Edit Items wizard data (inline modal on Details page)
|
||||
var wizardCosts = await _pricingService.GetOperatingCostsAsync(job.CompanyId);
|
||||
await PopulateJobItemDropDownsAsync(job.CompanyId, wizardCosts?.OvenOperatingCostPerHour ?? 45m);
|
||||
ViewBag.WizardTaxPercent = wizardCosts?.TaxPercent ?? 0m;
|
||||
ViewBag.WizardTaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, wizardCosts?.TaxPercent ?? 0m);
|
||||
|
||||
// Internal pricing breakdown (not printed — mirrors quote details breakdown)
|
||||
var breakdownItems = job.JobItems
|
||||
.Where(ji => !ji.IsDeleted)
|
||||
.Select(ji => new CreateQuoteItemDto
|
||||
{
|
||||
Description = ji.Description,
|
||||
Quantity = ji.Quantity,
|
||||
SurfaceAreaSqFt = ji.SurfaceAreaSqFt,
|
||||
EstimatedMinutes = ji.EstimatedMinutes,
|
||||
CatalogItemId = ji.CatalogItemId,
|
||||
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && !ji.Coats.Any() && !ji.IsSalesItem),
|
||||
IsLaborItem = ji.IsLaborItem,
|
||||
IsSalesItem = ji.IsSalesItem,
|
||||
ManualUnitPrice = ji.ManualUnitPrice ?? ((ji.IsGenericItem || ji.IsSalesItem) ? ji.UnitPrice : (decimal?)null),
|
||||
PowderCostOverride = ji.PowderCostOverride,
|
||||
IncludePrepCost = ji.IncludePrepCost,
|
||||
Complexity = ji.Complexity,
|
||||
Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto
|
||||
{
|
||||
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb,
|
||||
PowderToOrder = c.PowderToOrder
|
||||
}).ToList(),
|
||||
PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto
|
||||
{
|
||||
PrepServiceId = ps.PrepServiceId,
|
||||
EstimatedMinutes = ps.EstimatedMinutes
|
||||
}).ToList()
|
||||
}).ToList();
|
||||
|
||||
if (breakdownItems.Any())
|
||||
// Display the pricing snapshot stored when items were last saved.
|
||||
// Never recalculate on load — operating cost changes must not retroactively alter existing jobs.
|
||||
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
|
||||
{
|
||||
var pr = await _pricingService.CalculateQuoteTotalsAsync(
|
||||
breakdownItems, job.CompanyId, job.CustomerId,
|
||||
wizardCosts?.TaxPercent ?? 0m,
|
||||
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
|
||||
job.OvenCostId, 1, null);
|
||||
|
||||
ViewBag.JobPricingBreakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
|
||||
}
|
||||
else if (job.FinalPrice > 0)
|
||||
{
|
||||
// Legacy job created before snapshot was introduced — show what we have stored
|
||||
ViewBag.JobPricingBreakdown = new QuotePricingBreakdownDto
|
||||
{
|
||||
MaterialCosts = pr.MaterialCosts,
|
||||
LaborCosts = pr.LaborCosts,
|
||||
EquipmentCosts = pr.EquipmentCosts,
|
||||
ItemsSubtotal = pr.ItemsSubtotal,
|
||||
OvenBatchCost = pr.OvenBatchCost,
|
||||
OvenBatches = pr.OvenBatches,
|
||||
OvenCycleMinutes = pr.OvenCycleMinutes > 0 ? pr.OvenCycleMinutes : (wizardCosts?.DefaultOvenCycleMinutes ?? 0),
|
||||
FacilityOverheadCost = pr.FacilityOverheadCost,
|
||||
FacilityOverheadRatePerHour = pr.FacilityOverheadRatePerHour,
|
||||
ShopSuppliesAmount = pr.ShopSuppliesAmount,
|
||||
ShopSuppliesPercent = pr.ShopSuppliesPercent,
|
||||
OverheadCosts = pr.OverheadCosts,
|
||||
OverheadPercent = pr.OverheadPercent,
|
||||
ProfitMargin = pr.ProfitMargin,
|
||||
ProfitPercent = pr.ProfitPercent,
|
||||
SubtotalBeforeDiscount = pr.SubtotalBeforeDiscount,
|
||||
DiscountAmount = pr.DiscountAmount,
|
||||
DiscountPercent = pr.DiscountPercent,
|
||||
SubtotalAfterDiscount = pr.SubtotalAfterDiscount,
|
||||
RushFee = pr.RushFee,
|
||||
TaxAmount = pr.TaxAmount,
|
||||
TaxPercent = pr.TaxPercent,
|
||||
Total = pr.Total
|
||||
OvenBatchCost = job.OvenBatchCost,
|
||||
OvenBatches = job.OvenBatches,
|
||||
ShopSuppliesAmount = job.ShopSuppliesAmount,
|
||||
ShopSuppliesPercent = job.ShopSuppliesPercent,
|
||||
Total = job.FinalPrice
|
||||
};
|
||||
}
|
||||
ViewBag.ComplexitySimplePercent = wizardCosts?.ComplexitySimplePercent ?? 0m;
|
||||
@@ -506,6 +458,7 @@ public class JobsController : Controller
|
||||
isGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && !ji.Coats.Any() && !ji.IsSalesItem),
|
||||
isLaborItem = ji.IsLaborItem,
|
||||
isSalesItem = ji.IsSalesItem,
|
||||
isAiItem = ji.IsAiItem,
|
||||
sku = ji.Sku,
|
||||
requiresSandblasting = ji.RequiresSandblasting,
|
||||
requiresMasking = ji.RequiresMasking,
|
||||
@@ -1106,6 +1059,7 @@ public class JobsController : Controller
|
||||
CustomerId = dto.CustomerId,
|
||||
QuoteId = dto.QuoteId,
|
||||
AssignedUserId = dto.AssignedUserId,
|
||||
OvenCostId = dto.OvenCostId,
|
||||
Description = dto.Description,
|
||||
JobPriorityId = dto.JobPriorityId,
|
||||
JobStatusId = pendingStatus?.Id ?? 1,
|
||||
@@ -1167,15 +1121,23 @@ public class JobsController : Controller
|
||||
|
||||
// Recalculate total from wizard items
|
||||
var createCosts = await _pricingService.GetOperatingCostsAsync(companyId);
|
||||
decimal? createOvenRate = null;
|
||||
if (dto.OvenCostId.HasValue)
|
||||
{
|
||||
var createOven = await _unitOfWork.OvenCosts.GetByIdAsync(dto.OvenCostId.Value);
|
||||
if (createOven != null && createOven.CompanyId == companyId)
|
||||
createOvenRate = createOven.CostPerHour;
|
||||
}
|
||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||
dto.JobItems, companyId, dto.CustomerId,
|
||||
createCosts?.TaxPercent ?? 0m,
|
||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, job.OvenCostId, 1, null);
|
||||
await GetEffectiveTaxPercentAsync(dto.CustomerId, createCosts?.TaxPercent ?? 0m),
|
||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, createOvenRate, job.OvenBatches, job.OvenCycleMinutes);
|
||||
|
||||
job.FinalPrice = totals.Total;
|
||||
job.OvenBatchCost = totals.OvenBatchCost;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
|
||||
job.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.Jobs.UpdateAsync(job);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
@@ -1262,6 +1224,7 @@ public class JobsController : Controller
|
||||
PowderCostOverride = ji.PowderCostOverride,
|
||||
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0),
|
||||
IsLaborItem = ji.IsLaborItem,
|
||||
IsAiItem = ji.IsAiItem,
|
||||
RequiresSandblasting = ji.RequiresSandblasting,
|
||||
RequiresMasking = ji.RequiresMasking,
|
||||
Notes = ji.Notes,
|
||||
@@ -1626,14 +1589,22 @@ public class JobsController : Controller
|
||||
if (dto.JobItems.Any())
|
||||
{
|
||||
var editCosts = await _pricingService.GetOperatingCostsAsync(companyId);
|
||||
decimal? editOvenRate = null;
|
||||
if (job.OvenCostId.HasValue)
|
||||
{
|
||||
var editOven = await _unitOfWork.OvenCosts.GetByIdAsync(job.OvenCostId.Value);
|
||||
if (editOven != null && editOven.CompanyId == companyId)
|
||||
editOvenRate = editOven.CostPerHour;
|
||||
}
|
||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||
dto.JobItems, companyId, dto.CustomerId,
|
||||
editCosts?.TaxPercent ?? 0m,
|
||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, job.OvenCostId, 1, null);
|
||||
job.FinalPrice = totals.Total;
|
||||
job.OvenBatchCost = totals.OvenBatchCost;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
await GetEffectiveTaxPercentAsync(dto.CustomerId, editCosts?.TaxPercent ?? 0m),
|
||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, editOvenRate, job.OvenBatches, job.OvenCycleMinutes);
|
||||
job.FinalPrice = totals.Total;
|
||||
job.OvenBatchCost = totals.OvenBatchCost;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
|
||||
}
|
||||
|
||||
// Save change history records
|
||||
@@ -2926,6 +2897,7 @@ public class JobsController : Controller
|
||||
PowderCostOverride = ji.PowderCostOverride,
|
||||
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0),
|
||||
IsLaborItem = ji.IsLaborItem,
|
||||
IsAiItem = ji.IsAiItem,
|
||||
RequiresSandblasting = ji.RequiresSandblasting,
|
||||
RequiresMasking = ji.RequiresMasking,
|
||||
Notes = ji.Notes,
|
||||
@@ -2955,11 +2927,14 @@ public class JobsController : Controller
|
||||
|
||||
var viewModel = new JobEditItemsViewModel
|
||||
{
|
||||
JobId = job.Id,
|
||||
JobNumber = job.JobNumber,
|
||||
CustomerId = job.CustomerId,
|
||||
TaxPercent = costs?.TaxPercent ?? 0m,
|
||||
JobItems = existingItems
|
||||
JobId = job.Id,
|
||||
JobNumber = job.JobNumber,
|
||||
CustomerId = job.CustomerId,
|
||||
TaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m),
|
||||
OvenCostId = job.OvenCostId,
|
||||
OvenBatches = job.OvenBatches > 0 ? job.OvenBatches : 1,
|
||||
OvenCycleMinutes = job.OvenCycleMinutes,
|
||||
JobItems = existingItems
|
||||
};
|
||||
|
||||
await PopulateJobItemDropDownsAsync(currentUser.CompanyId, costs?.OvenOperatingCostPerHour ?? 45m);
|
||||
@@ -2992,7 +2967,7 @@ public class JobsController : Controller
|
||||
{
|
||||
ModelState.AddModelError("", "Please add at least one job item.");
|
||||
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
||||
model.TaxPercent = costs?.TaxPercent ?? 0m;
|
||||
model.TaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m);
|
||||
await PopulateJobItemDropDownsAsync(currentUser.CompanyId, costs?.OvenOperatingCostPerHour ?? 45m);
|
||||
ViewBag.ComplexitySimplePercent = costs?.ComplexitySimplePercent ?? 0m;
|
||||
ViewBag.ComplexityModeratePercent = costs?.ComplexityModeratePercent ?? 5m;
|
||||
@@ -3037,15 +3012,26 @@ public class JobsController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate full total (overhead, margins, tax) to match what the wizard displays
|
||||
// Calculate full total (overhead, margins, tax) matching what Details shows
|
||||
decimal? ovenRateOverride = null;
|
||||
if (job.OvenCostId.HasValue)
|
||||
{
|
||||
var oven = await _unitOfWork.OvenCosts.GetByIdAsync(job.OvenCostId.Value);
|
||||
if (oven != null && oven.CompanyId == currentUser.CompanyId)
|
||||
ovenRateOverride = oven.CostPerHour;
|
||||
}
|
||||
var updateCosts = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||
model.JobItems, currentUser.CompanyId, job.CustomerId,
|
||||
model.TaxPercent, "None", 0, false, job.OvenCostId, 1, null);
|
||||
await GetEffectiveTaxPercentAsync(job.CustomerId, updateCosts?.TaxPercent ?? 0m),
|
||||
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
|
||||
ovenRateOverride, job.OvenBatches, job.OvenCycleMinutes);
|
||||
|
||||
job.FinalPrice = totals.Total;
|
||||
job.OvenBatchCost = totals.OvenBatchCost;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
job.FinalPrice = totals.Total;
|
||||
job.OvenBatchCost = totals.OvenBatchCost;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
|
||||
job.UpdatedAt = DateTime.UtcNow;
|
||||
job.UpdatedBy = currentUser.UserName;
|
||||
await _unitOfWork.Jobs.UpdateAsync(job);
|
||||
@@ -3059,7 +3045,7 @@ public class JobsController : Controller
|
||||
_logger.LogError(ex, "Error updating items for job {JobId}", job.Id);
|
||||
TempData["Error"] = "An error occurred while saving job items.";
|
||||
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
||||
model.TaxPercent = costs?.TaxPercent ?? 0m;
|
||||
model.TaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m);
|
||||
await PopulateJobItemDropDownsAsync(currentUser.CompanyId, costs?.OvenOperatingCostPerHour ?? 45m);
|
||||
return View("EditItems", model);
|
||||
}
|
||||
@@ -3101,30 +3087,47 @@ public class JobsController : Controller
|
||||
CatalogItemId = ji.CatalogItemId,
|
||||
IsGenericItem = ji.IsGenericItem,
|
||||
IsLaborItem = ji.IsLaborItem,
|
||||
ManualUnitPrice = ji.ManualUnitPrice,
|
||||
Coats = ji.Coats.Select(c => new CreateQuoteItemCoatDto
|
||||
IsSalesItem = ji.IsSalesItem,
|
||||
IsAiItem = ji.IsAiItem,
|
||||
ManualUnitPrice = ji.ManualUnitPrice ?? ((ji.IsGenericItem || ji.IsSalesItem) ? ji.UnitPrice : (decimal?)null),
|
||||
IncludePrepCost = ji.IncludePrepCost,
|
||||
Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto
|
||||
{
|
||||
InventoryItemId = c.InventoryItemId,
|
||||
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb
|
||||
}).ToList()
|
||||
}).ToList();
|
||||
|
||||
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
||||
if (remainingDtos.Any())
|
||||
{
|
||||
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
||||
decimal? deleteOvenRate = null;
|
||||
if (job.OvenCostId.HasValue)
|
||||
{
|
||||
var deleteOven = await _unitOfWork.OvenCosts.GetByIdAsync(job.OvenCostId.Value);
|
||||
if (deleteOven != null && deleteOven.CompanyId == currentUser.CompanyId)
|
||||
deleteOvenRate = deleteOven.CostPerHour;
|
||||
}
|
||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||
remainingDtos, currentUser.CompanyId, job.CustomerId,
|
||||
costs?.TaxPercent ?? 0m, "None", 0, false, null, 1, null);
|
||||
job.FinalPrice = totals.Total;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m),
|
||||
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
|
||||
deleteOvenRate, job.OvenBatches, job.OvenCycleMinutes);
|
||||
job.FinalPrice = totals.Total;
|
||||
job.OvenBatchCost = totals.OvenBatchCost;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
|
||||
}
|
||||
else
|
||||
{
|
||||
job.FinalPrice = 0;
|
||||
job.ShopSuppliesAmount = 0;
|
||||
job.ShopSuppliesPercent = 0;
|
||||
job.FinalPrice = 0;
|
||||
job.OvenBatchCost = 0;
|
||||
job.ShopSuppliesAmount = 0;
|
||||
job.ShopSuppliesPercent = 0;
|
||||
job.PricingBreakdownJson = null;
|
||||
}
|
||||
|
||||
job.UpdatedAt = DateTime.UtcNow;
|
||||
@@ -3234,6 +3237,57 @@ public class JobsController : Controller
|
||||
return $"{string.Join(" > ", path)} > {item.Name}{sku} - {item.DefaultPrice:C}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a <see cref="QuotePricingResult"/> into the DTO used for both display and JSON snapshot storage.
|
||||
/// All save paths (Create, Edit, UpdateItems, DeleteJobItem) call this so the snapshot is always consistent.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Returns the effective tax rate for a job, respecting customer tax-exempt status.
|
||||
/// Always call this instead of using costs.TaxPercent directly so tax-exempt customers
|
||||
/// are never charged tax when a job is saved or recalculated.
|
||||
/// </summary>
|
||||
private async Task<decimal> GetEffectiveTaxPercentAsync(int? customerId, decimal companyDefaultRate)
|
||||
{
|
||||
if (customerId is > 0)
|
||||
{
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(customerId.Value);
|
||||
if (customer?.IsTaxExempt == true) return 0m;
|
||||
}
|
||||
return companyDefaultRate;
|
||||
}
|
||||
|
||||
private static QuotePricingBreakdownDto BuildPricingSnapshotDto(QuotePricingResult pr) =>
|
||||
new QuotePricingBreakdownDto
|
||||
{
|
||||
MaterialCosts = pr.MaterialCosts,
|
||||
LaborCosts = pr.LaborCosts,
|
||||
EquipmentCosts = pr.EquipmentCosts,
|
||||
ItemsSubtotal = pr.ItemsSubtotal,
|
||||
OvenBatchCost = pr.OvenBatchCost,
|
||||
OvenBatches = pr.OvenBatches,
|
||||
OvenCycleMinutes = pr.OvenCycleMinutes,
|
||||
FacilityOverheadCost = pr.FacilityOverheadCost,
|
||||
FacilityOverheadRatePerHour = pr.FacilityOverheadRatePerHour,
|
||||
ShopSuppliesAmount = pr.ShopSuppliesAmount,
|
||||
ShopSuppliesPercent = pr.ShopSuppliesPercent,
|
||||
OverheadCosts = pr.OverheadCosts,
|
||||
OverheadPercent = pr.OverheadPercent,
|
||||
ProfitMargin = pr.ProfitMargin,
|
||||
ProfitPercent = pr.ProfitPercent,
|
||||
SubtotalBeforeDiscount = pr.SubtotalBeforeDiscount,
|
||||
PricingTierDiscountAmount = pr.PricingTierDiscountAmount,
|
||||
PricingTierDiscountPercent = pr.PricingTierDiscountPercent,
|
||||
QuoteDiscountAmount = pr.QuoteDiscountAmount,
|
||||
QuoteDiscountPercent = pr.QuoteDiscountPercent,
|
||||
DiscountAmount = pr.DiscountAmount,
|
||||
DiscountPercent = pr.DiscountPercent,
|
||||
SubtotalAfterDiscount = pr.SubtotalAfterDiscount,
|
||||
RushFee = pr.RushFee,
|
||||
TaxAmount = pr.TaxAmount,
|
||||
TaxPercent = pr.TaxPercent,
|
||||
Total = pr.Total
|
||||
};
|
||||
|
||||
#endregion
|
||||
|
||||
#region Item Pricing (AJAX)
|
||||
|
||||
@@ -2,6 +2,7 @@ using AutoMapper;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PowderCoating.Application.DTOs.Kiosk;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
@@ -39,6 +40,9 @@ public class KioskController : Controller
|
||||
private readonly IHubContext<KioskHub> _kioskHub;
|
||||
private readonly ILogger<KioskController> _logger;
|
||||
private readonly ICompanyLogoService _logoService;
|
||||
private readonly IMemoryCache _cache;
|
||||
|
||||
private static string SmsConsentCacheKey(int companyId) => $"kiosk-sms-consent:{companyId}";
|
||||
|
||||
/// <summary>Initialises all dependencies for the kiosk controller.</summary>
|
||||
public KioskController(
|
||||
@@ -49,7 +53,8 @@ public class KioskController : Controller
|
||||
IEmailService emailService,
|
||||
IHubContext<KioskHub> kioskHub,
|
||||
ILogger<KioskController> logger,
|
||||
ICompanyLogoService logoService)
|
||||
ICompanyLogoService logoService,
|
||||
IMemoryCache cache)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
@@ -59,6 +64,7 @@ public class KioskController : Controller
|
||||
_kioskHub = kioskHub;
|
||||
_logger = logger;
|
||||
_logoService = logoService;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -68,7 +74,8 @@ public class KioskController : Controller
|
||||
/// <summary>
|
||||
/// Idle branded screen displayed on the front-desk tablet.
|
||||
/// Validates the KioskDevice cookie; returns 403 if missing or token mismatch.
|
||||
/// The view connects to KioskHub and listens for StartIntake events.
|
||||
/// The view polls /Kiosk/PollSession every 3 seconds and navigates when staff
|
||||
/// triggers a session via the Dashboard "Start Intake" button.
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Welcome()
|
||||
@@ -86,6 +93,149 @@ public class KioskController : Controller
|
||||
return View();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight polling endpoint called every 3 seconds by the kiosk Welcome screen.
|
||||
/// Returns the most recent InPerson KioskSession created in the last 60 seconds so
|
||||
/// the tablet can navigate without relying on SignalR (which Azure App Service blocks
|
||||
/// for anonymous WebSocket/SSE connections through its ingress proxy).
|
||||
/// </summary>
|
||||
[AllowAnonymous, HttpGet]
|
||||
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
|
||||
public async Task<IActionResult> PollSession()
|
||||
{
|
||||
var cookie = ReadKioskCookie();
|
||||
if (cookie == null) return Json(new { hasSession = false });
|
||||
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(cookie.Value.companyId, ignoreQueryFilters: true);
|
||||
if (company == null || company.KioskActivationToken != cookie.Value.token)
|
||||
return Json(new { hasSession = false });
|
||||
|
||||
// Check for a staff-pushed SMS consent request before checking for intake sessions.
|
||||
if (_cache.TryGetValue(SmsConsentCacheKey(cookie.Value.companyId), out (int customerId, string customerName) pending))
|
||||
return Json(new { hasSession = false, smsConsentPending = true, customerId = pending.customerId, customerName = pending.customerName });
|
||||
|
||||
var window = DateTime.UtcNow.AddSeconds(-60);
|
||||
var session = await _unitOfWork.KioskSessions.FirstOrDefaultAsync(
|
||||
s => s.CompanyId == cookie.Value.companyId
|
||||
&& s.SessionType == KioskSessionType.InPerson
|
||||
&& s.Status == KioskSessionStatus.Active
|
||||
&& s.CreatedAt >= window,
|
||||
ignoreQueryFilters: true);
|
||||
|
||||
if (session == null) return Json(new { hasSession = false });
|
||||
return Json(new { hasSession = true, sessionToken = session.SessionToken });
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// SMS CONSENT (staff pushes to kiosk; customer agrees on tablet)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Staff calls this (authenticated) from the Customer Details page to push an SMS
|
||||
/// consent request to the front-desk kiosk tablet. Stores the customer ID in
|
||||
/// IMemoryCache under a company-scoped key; the kiosk's PollSession endpoint picks
|
||||
/// it up and returns smsConsentPending so the tablet can navigate to the consent page.
|
||||
/// The cache entry expires in 10 minutes in case the customer never approaches the tablet.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> PushSmsConsent(int customerId)
|
||||
{
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(customerId);
|
||||
if (customer == null) return Json(new { success = false, message = "Customer not found." });
|
||||
|
||||
if (customer.NotifyBySms)
|
||||
return Json(new { success = false, message = "Customer has already given SMS consent." });
|
||||
|
||||
var companyId = customer.CompanyId;
|
||||
var name = !string.IsNullOrWhiteSpace(customer.ContactFirstName)
|
||||
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
|
||||
: customer.CompanyName ?? "Customer";
|
||||
|
||||
_cache.Set(SmsConsentCacheKey(companyId), (customerId, name),
|
||||
new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) });
|
||||
|
||||
_logger.LogInformation("SMS consent pushed to kiosk for customer {CustomerId} by staff", customerId);
|
||||
return Json(new { success = true });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a pending kiosk SMS consent request, freeing the kiosk to return to the Welcome
|
||||
/// screen. Called by staff if they pushed consent accidentally or the customer isn't coming.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public IActionResult CancelSmsConsent()
|
||||
{
|
||||
var companyId = HttpContext.User.FindFirst("CompanyId")?.Value;
|
||||
if (int.TryParse(companyId, out var cid))
|
||||
_cache.Remove(SmsConsentCacheKey(cid));
|
||||
return Json(new { success = true });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Displays the full-screen SMS consent form on the kiosk tablet (anonymous, kiosk layout).
|
||||
/// Loads the customer by ID with ignoreQueryFilters because the kiosk has no tenant context.
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> SmsConsent(int id)
|
||||
{
|
||||
var cookie = ReadKioskCookie();
|
||||
if (cookie == null) return Forbid();
|
||||
|
||||
// Clear the pending entry immediately — the kiosk is now showing the form,
|
||||
// so Welcome must not redirect again if the customer cancels or navigates back.
|
||||
_cache.Remove(SmsConsentCacheKey(cookie.Value.companyId));
|
||||
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(id, ignoreQueryFilters: true);
|
||||
if (customer == null) return NotFound();
|
||||
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(cookie.Value.companyId, ignoreQueryFilters: true);
|
||||
ViewBag.CompanyName = company?.CompanyName;
|
||||
ViewBag.CompanyLogoUrl = !string.IsNullOrEmpty(company?.LogoFilePath) ? Url.Action("Logo", "Kiosk") : null;
|
||||
ViewBag.ShowInactivityTimer = false;
|
||||
ViewBag.CustomerName = !string.IsNullOrWhiteSpace(customer.ContactFirstName)
|
||||
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
|
||||
: customer.CompanyName ?? "Customer";
|
||||
|
||||
return View(id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the customer's SMS consent from the kiosk tablet.
|
||||
/// Sets NotifyBySms, SmsConsentedAt, SmsConsentMethod = "KioskInPerson" on the customer record.
|
||||
/// Cache is already cleared by the GET; this handles the agree/decline outcome.
|
||||
/// </summary>
|
||||
[AllowAnonymous, HttpPost]
|
||||
public async Task<IActionResult> SmsConsent(int id, bool agreed)
|
||||
{
|
||||
var cookie = ReadKioskCookie();
|
||||
if (cookie == null) return Forbid();
|
||||
|
||||
if (agreed)
|
||||
{
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(id, ignoreQueryFilters: true);
|
||||
if (customer != null)
|
||||
{
|
||||
customer.NotifyBySms = true;
|
||||
customer.SmsConsentedAt = DateTime.UtcNow;
|
||||
customer.SmsConsentMethod = "KioskInPerson";
|
||||
customer.SmsOptedOutAt = null;
|
||||
await _unitOfWork.Customers.UpdateAsync(customer);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
_logger.LogInformation("SMS consent recorded via kiosk for customer {CustomerId}", id);
|
||||
|
||||
await _inApp.CreateAsync(
|
||||
customer.CompanyId,
|
||||
"SMS Consent Recorded",
|
||||
$"{customer.ContactFirstName} {customer.ContactLastName} agreed to SMS notifications on the kiosk.",
|
||||
"KioskConsent",
|
||||
link: $"/Customers/Details/{id}",
|
||||
customerId: id);
|
||||
}
|
||||
}
|
||||
|
||||
return Redirect("/Kiosk/Welcome");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serves the company logo for anonymous kiosk pages. Resolves the company from the
|
||||
/// KioskDevice cookie so no tenant context is needed on the anonymous request.
|
||||
@@ -261,7 +411,7 @@ public class KioskController : Controller
|
||||
|
||||
await PopulateKioskViewBagFromSession(session);
|
||||
ViewBag.KioskStep = 1;
|
||||
return View(new SubmitKioskContactDto
|
||||
return View("Intake/Contact", new SubmitKioskContactDto
|
||||
{
|
||||
FirstName = session.CustomerFirstName,
|
||||
LastName = session.CustomerLastName,
|
||||
@@ -283,7 +433,7 @@ public class KioskController : Controller
|
||||
{
|
||||
await PopulateKioskViewBagFromSession(session);
|
||||
ViewBag.KioskStep = 1;
|
||||
return View(dto);
|
||||
return View("Intake/Contact", dto);
|
||||
}
|
||||
|
||||
session.CustomerFirstName = dto.FirstName.Trim();
|
||||
@@ -308,7 +458,7 @@ public class KioskController : Controller
|
||||
|
||||
await PopulateKioskViewBagFromSession(session);
|
||||
ViewBag.KioskStep = 2;
|
||||
return View(new SubmitKioskJobDto
|
||||
return View("Intake/Job", new SubmitKioskJobDto
|
||||
{
|
||||
JobDescription = session.JobDescription,
|
||||
HowDidYouHearAboutUs = session.HowDidYouHearAboutUs
|
||||
@@ -327,7 +477,7 @@ public class KioskController : Controller
|
||||
{
|
||||
await PopulateKioskViewBagFromSession(session);
|
||||
ViewBag.KioskStep = 2;
|
||||
return View(dto);
|
||||
return View("Intake/Job", dto);
|
||||
}
|
||||
|
||||
session.JobDescription = dto.JobDescription.Trim();
|
||||
@@ -350,7 +500,7 @@ public class KioskController : Controller
|
||||
await PopulateKioskViewBagFromSession(session);
|
||||
ViewBag.KioskStep = 3;
|
||||
ViewBag.IsInPerson = session.SessionType == KioskSessionType.InPerson;
|
||||
return View(new SubmitKioskTermsDto());
|
||||
return View("Intake/Terms", new SubmitKioskTermsDto());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -364,6 +514,9 @@ public class KioskController : Controller
|
||||
var session = await LoadSessionAsync(token);
|
||||
if (session == null) return View("KioskError", "Session not found.");
|
||||
|
||||
// Expired/already-submitted sessions go straight to Confirmation
|
||||
if (!await ValidateSessionState(session)) return RedirectToAction(nameof(Confirmation), new { token });
|
||||
|
||||
// Require signature for in-person sessions
|
||||
if (session.SessionType == KioskSessionType.InPerson &&
|
||||
string.IsNullOrEmpty(dto.SignatureDataBase64))
|
||||
@@ -376,7 +529,7 @@ public class KioskController : Controller
|
||||
await PopulateKioskViewBagFromSession(session);
|
||||
ViewBag.KioskStep = 3;
|
||||
ViewBag.IsInPerson = session.SessionType == KioskSessionType.InPerson;
|
||||
return View(dto);
|
||||
return View("Intake/Terms", dto);
|
||||
}
|
||||
|
||||
session.AgreedToTerms = true;
|
||||
@@ -393,8 +546,9 @@ public class KioskController : Controller
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing kiosk submission for session {SessionToken}", token);
|
||||
// Don't fail the customer-facing page — save what we have and let staff convert manually
|
||||
await _unitOfWork.CompleteAsync();
|
||||
// Customer-facing page always succeeds — staff can convert the session manually.
|
||||
// Persist the session's agreed/submitted state even if job creation failed.
|
||||
try { await _unitOfWork.CompleteAsync(); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Confirmation), new { token });
|
||||
@@ -413,7 +567,7 @@ public class KioskController : Controller
|
||||
ViewBag.ShowInactivityTimer = false; // Handled by the countdown JS in the view
|
||||
ViewBag.IsInPerson = session.SessionType == KioskSessionType.InPerson;
|
||||
ViewBag.FirstName = session.CustomerFirstName;
|
||||
return View();
|
||||
return View("Intake/Confirmation");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -449,6 +603,7 @@ public class KioskController : Controller
|
||||
ExpiresAt = s.ExpiresAt,
|
||||
LinkedCustomerId = s.LinkedCustomerId,
|
||||
LinkedJobId = s.LinkedJobId,
|
||||
LinkedQuoteId = s.LinkedQuoteId,
|
||||
RemoteLinkEmail = s.RemoteLinkEmail
|
||||
})
|
||||
.ToList();
|
||||
@@ -556,48 +711,117 @@ public class KioskController : Controller
|
||||
: "RemoteIntake";
|
||||
}
|
||||
|
||||
// 3. Create Job in Pending status
|
||||
var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
|
||||
var pendingStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Pending);
|
||||
// 3. Resolve company preference: create a Quote (default) or a Job
|
||||
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
|
||||
p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true);
|
||||
var intakeOutput = prefs?.KioskIntakeOutput ?? "Quote";
|
||||
var createQuote = !string.Equals(intakeOutput, "Job", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var jobNumber = await GenerateJobNumberAsync(companyId);
|
||||
var job = new Job
|
||||
session.LinkedCustomerId = customer!.Id;
|
||||
|
||||
if (createQuote)
|
||||
{
|
||||
CompanyId = companyId,
|
||||
CustomerId = customer!.Id,
|
||||
JobNumber = jobNumber,
|
||||
JobStatusId = pendingStatus?.Id ?? 1,
|
||||
SpecialInstructions = session.JobDescription,
|
||||
Description = $"Walk-in intake — {session.CustomerFirstName} {session.CustomerLastName}".Trim()
|
||||
};
|
||||
// 3a. Create a Draft Quote so staff can price and send for approval
|
||||
var quoteStatuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId);
|
||||
var draftStatus = quoteStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Draft);
|
||||
if (draftStatus == null)
|
||||
throw new InvalidOperationException($"No Draft quote status found for company {companyId}. Run Seed Data from Platform Management.");
|
||||
|
||||
await _unitOfWork.Jobs.AddAsync(job);
|
||||
var quoteNumber = await GenerateQuoteNumberAsync(companyId);
|
||||
var quote = new Quote
|
||||
{
|
||||
CompanyId = companyId,
|
||||
CustomerId = customer.Id,
|
||||
QuoteNumber = quoteNumber,
|
||||
QuoteStatusId = draftStatus.Id,
|
||||
Description = session.JobDescription,
|
||||
Notes = $"Source: {session.SessionType} kiosk intake",
|
||||
QuoteDate = DateTime.UtcNow,
|
||||
ExpirationDate = DateTime.UtcNow.AddDays(prefs?.DefaultQuoteValidityDays ?? 30)
|
||||
};
|
||||
|
||||
// 4. Update session links
|
||||
session.LinkedCustomerId = customer.Id;
|
||||
session.LinkedJobId = job.Id; // will be populated after SaveChanges below
|
||||
await _unitOfWork.Quotes.AddAsync(quote);
|
||||
await _unitOfWork.CompleteAsync(); // quote.Id now valid
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// job.Id is now set — update session again if needed
|
||||
if (session.LinkedJobId == 0)
|
||||
session.LinkedQuoteId = quote.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 3b. Create a Pending Job directly (for shops that price on the spot)
|
||||
var jobStatuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
|
||||
var pendingStatus = jobStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Pending);
|
||||
if (pendingStatus == null)
|
||||
throw new InvalidOperationException($"No Pending job status found for company {companyId}. Run Seed Data from Platform Management.");
|
||||
|
||||
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
|
||||
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL")
|
||||
?? priorities.FirstOrDefault();
|
||||
if (normalPriority == null)
|
||||
throw new InvalidOperationException($"No job priority rows found for company {companyId}. Run Seed Data from Platform Management.");
|
||||
|
||||
var jobNumber = await GenerateJobNumberAsync(companyId);
|
||||
var job = new Job
|
||||
{
|
||||
CompanyId = companyId,
|
||||
CustomerId = customer.Id,
|
||||
JobNumber = jobNumber,
|
||||
JobStatusId = pendingStatus.Id,
|
||||
JobPriorityId = normalPriority.Id,
|
||||
Description = session.JobDescription,
|
||||
SpecialInstructions = $"Source: {session.SessionType} kiosk intake"
|
||||
};
|
||||
|
||||
await _unitOfWork.Jobs.AddAsync(job);
|
||||
await _unitOfWork.CompleteAsync(); // job.Id now valid
|
||||
|
||||
session.LinkedJobId = job.Id;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
|
||||
// 4. Persist session links
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// 5. Fire staff notification
|
||||
var snippet = session.JobDescription.Length > 60 ? session.JobDescription[..60] + "…" : session.JobDescription;
|
||||
var fullName = $"{session.CustomerFirstName} {session.CustomerLastName}".Trim();
|
||||
var jobDesc = session.JobDescription ?? "";
|
||||
var snippet = jobDesc.Length > 60 ? jobDesc[..60] + "…" : jobDesc;
|
||||
var fullName = $"{session.CustomerFirstName} {session.CustomerLastName}".Trim();
|
||||
var intakeLabel = session.SessionType == KioskSessionType.Remote ? "Remote Intake" : "Walk-in Intake";
|
||||
await _inApp.CreateAsync(
|
||||
companyId,
|
||||
"Walk-in Intake Submitted",
|
||||
$"{intakeLabel} Submitted",
|
||||
$"{fullName} completed their intake form — {snippet}",
|
||||
"KioskIntake",
|
||||
link: $"/Kiosk/Intakes",
|
||||
customerId: customer.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates the next sequential quote number using the company's configured prefix.
|
||||
/// Mirrors GenerateQuoteNumberAsync in QuotesController — same format: PREFIX-YYMM-####.
|
||||
/// Implemented here because KioskController processes anonymous requests and cannot
|
||||
/// rely on ITenantContext to resolve the company ID.
|
||||
/// </summary>
|
||||
private async Task<string> GenerateQuoteNumberAsync(int companyId)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
|
||||
p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true);
|
||||
|
||||
var quotePrefix = !string.IsNullOrWhiteSpace(prefs?.QuoteNumberPrefix) ? prefs.QuoteNumberPrefix : "QT";
|
||||
var prefix = $"{quotePrefix}-{now:yy}{now:MM}";
|
||||
|
||||
var lastQuoteNumber = await _unitOfWork.Quotes.GetLastQuoteNumberByPrefixAsync(companyId, prefix);
|
||||
|
||||
if (lastQuoteNumber != null)
|
||||
{
|
||||
var lastNumberStr = lastQuoteNumber[(prefix.Length + 1)..];
|
||||
if (int.TryParse(lastNumberStr, out int lastNumber))
|
||||
return $"{prefix}-{(lastNumber + 1):D4}";
|
||||
}
|
||||
|
||||
return $"{prefix}-0001";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates the next sequential job number using the company's configured prefix.
|
||||
/// Mirrors the logic in JobsController.GenerateJobNumber() — same format: PREFIX-YYMM-####.
|
||||
@@ -675,7 +899,11 @@ public class KioskController : Controller
|
||||
? Url.Action("Logo", "Kiosk")
|
||||
: null;
|
||||
ViewBag.WelcomeUrl = "/Kiosk/Welcome";
|
||||
await Task.CompletedTask;
|
||||
|
||||
// Pass the intake output setting so Terms.cshtml can show matching wording
|
||||
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
|
||||
p => p.CompanyId == company.Id && !p.IsDeleted, ignoreQueryFilters: true);
|
||||
ViewBag.KioskIntakeOutput = prefs?.KioskIntakeOutput ?? "Quote";
|
||||
}
|
||||
|
||||
/// <summary>Loads the company from a session's CompanyId and populates ViewBag.</summary>
|
||||
@@ -687,5 +915,14 @@ public class KioskController : Controller
|
||||
|
||||
ViewBag.SessionToken = session.SessionToken;
|
||||
ViewBag.SessionType = session.SessionType;
|
||||
|
||||
// In-person kiosk: reset to Welcome screen after 45 s of inactivity so an
|
||||
// abandoned tablet doesn't stay on a customer's half-filled form indefinitely.
|
||||
// Remote sessions: customer is on their own phone — never redirect; they may
|
||||
// take several minutes between steps and have no KioskDevice cookie anyway.
|
||||
if (session.SessionType == KioskSessionType.InPerson)
|
||||
ViewBag.InactivityTimeoutMs = 45_000;
|
||||
else
|
||||
ViewBag.ShowInactivityTimer = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using AutoMapper;
|
||||
using System.Text.Json;
|
||||
using AutoMapper;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -2839,14 +2840,47 @@ public class QuotesController : Controller
|
||||
JobNumber = await GenerateJobNumberAsync(),
|
||||
CustomerId = quote.CustomerId ?? 0, // Should always have a customer by approval time
|
||||
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}",
|
||||
JobStatusId = approvedStatus?.Id ?? 1,
|
||||
JobPriorityId = selectedPriority?.Id ?? 1,
|
||||
QuotedPrice = quote.Total,
|
||||
FinalPrice = quote.Total,
|
||||
OvenBatchCost = quote.OvenBatchCost,
|
||||
ShopSuppliesAmount = quote.ShopSuppliesAmount,
|
||||
ShopSuppliesPercent = quote.ShopSuppliesPercent,
|
||||
PricingBreakdownJson = JsonSerializer.Serialize(new QuotePricingBreakdownDto
|
||||
{
|
||||
MaterialCosts = quote.MaterialCosts,
|
||||
LaborCosts = quote.LaborCosts,
|
||||
EquipmentCosts = quote.EquipmentCosts,
|
||||
ItemsSubtotal = quote.ItemsSubtotal,
|
||||
OvenBatchCost = quote.OvenBatchCost,
|
||||
OvenBatches = quote.OvenBatches,
|
||||
OvenCycleMinutes = quote.OvenCycleMinutes ?? 0,
|
||||
FacilityOverheadCost = quote.FacilityOverheadCost,
|
||||
FacilityOverheadRatePerHour = quote.FacilityOverheadRatePerHour,
|
||||
ShopSuppliesAmount = quote.ShopSuppliesAmount,
|
||||
ShopSuppliesPercent = quote.ShopSuppliesPercent,
|
||||
OverheadCosts = quote.OverheadAmount,
|
||||
OverheadPercent = quote.OverheadPercent,
|
||||
ProfitMargin = quote.ProfitMargin,
|
||||
ProfitPercent = quote.ProfitPercent,
|
||||
SubtotalBeforeDiscount = quote.SubTotal,
|
||||
PricingTierDiscountAmount = quote.PricingTierDiscountAmount,
|
||||
PricingTierDiscountPercent = quote.PricingTierDiscountPercent,
|
||||
QuoteDiscountAmount = quote.QuoteDiscountAmount,
|
||||
QuoteDiscountPercent = quote.QuoteDiscountPercent,
|
||||
DiscountAmount = quote.DiscountAmount,
|
||||
DiscountPercent = quote.DiscountPercent,
|
||||
SubtotalAfterDiscount = quote.SubtotalAfterDiscount,
|
||||
RushFee = quote.RushFee,
|
||||
TaxAmount = quote.TaxAmount,
|
||||
TaxPercent = quote.TaxPercent,
|
||||
Total = quote.Total
|
||||
}),
|
||||
CustomerPO = quote.CustomerPO,
|
||||
InternalNotes = quote.Notes, // Copy internal notes from quote
|
||||
IsCustomerApproved = true,
|
||||
|
||||
@@ -109,6 +109,7 @@ public static class HelpKnowledgeBase
|
||||
- Job Priority Board → /JobsPriority
|
||||
- Online Payments → /Invoices/OnlinePayments
|
||||
- Gift Certificates → /GiftCertificates
|
||||
- Intake Sessions → /Kiosk/Intakes (walk-in and remote intake sessions submitted via the kiosk tablet)
|
||||
|
||||
**Inventory section:**
|
||||
- Catalog Items → /CatalogItems
|
||||
@@ -1265,6 +1266,60 @@ public static class HelpKnowledgeBase
|
||||
|
||||
---
|
||||
|
||||
## CUSTOMER INTAKE KIOSK
|
||||
|
||||
**Where:** Kiosk Setup → [/Kiosk/Activate](/Kiosk/Activate) | Intake Sessions → [/Kiosk/Intakes](/Kiosk/Intakes)
|
||||
|
||||
**What it does:** Lets walk-in customers fill out their own intake form on a front-desk tablet. On submission, a Customer record and either a Draft Quote or a Pending Job are auto-created (controlled by the Kiosk Output Setting), and staff receive an in-app notification. Also supports remote intake via email link so customers fill out the form on their own phone before arriving.
|
||||
|
||||
**Kiosk Output Setting (Company Settings → Kiosk tab):**
|
||||
- "Create a Quote" (default) — creates a Draft quote on submission; terms shown to customer say "subject to a formal quote." Best for shops that price after seeing the parts.
|
||||
- "Create a Job" — creates a Pending job on submission; terms say "team member will reach out about pricing." Best for shops that price on the spot.
|
||||
|
||||
**Setup (one-time per device):**
|
||||
1. Go to Settings → Kiosk Setup (or /Kiosk/Activate)
|
||||
2. Click Activate Kiosk — generates a secure activation token and sets a device cookie (365-day lifespan)
|
||||
3. On the tablet browser, navigate to /Kiosk/Welcome — the tablet is now in kiosk mode
|
||||
4. Add to Home Screen on iOS/Android for a full-screen PWA experience that preserves camera permissions
|
||||
|
||||
**Starting an in-person intake:**
|
||||
1. Customer approaches the tablet — it shows the Welcome screen with company logo and a green "Ready" dot
|
||||
2. Staff member clicks "Start Intake" on the Dashboard (Kiosk card)
|
||||
3. Tablet picks up the new session within 3 seconds and auto-navigates to the intake form
|
||||
4. Customer completes 3 steps: Contact info → Job description → Terms & drawn signature
|
||||
5. On submit: thank-you screen shown, kiosk returns to Welcome after 30 seconds
|
||||
6. If idle for 45 seconds during any intake step, the form resets to the Welcome screen automatically
|
||||
|
||||
**Sending a remote intake link:**
|
||||
- Click "Send Intake Link" on the Dashboard Kiosk card OR from /Kiosk/Intakes → Send Intake Link
|
||||
- Enter the customer's email → they receive a link to complete the form on their own device
|
||||
- Remote sessions use a checkbox agreement instead of a drawn signature
|
||||
|
||||
**What happens on submission:**
|
||||
- Customer is matched by email (first), then phone; if no match, a new non-commercial customer is created
|
||||
- A Draft Quote or Pending Job is created depending on the Kiosk Output Setting (see above)
|
||||
- SMS opt-in updates the customer record with NotifyBySms = true and a TCPA-compliant consent timestamp
|
||||
- In-app notification fires: "Walk-in Intake Submitted" (in-person) or "Remote Intake Submitted" (remote link) with a link to /Kiosk/Intakes
|
||||
|
||||
**Reviewing submissions (Intake Sessions page):**
|
||||
- Filter tabs: All / Submitted / Pending / Expired
|
||||
- Each row shows customer name, phone, email, job description snippet, session type badge, SMS opt-in icon
|
||||
- "View Quote" button → appears in Quote mode; opens the auto-created Draft quote for pricing and review
|
||||
- "View Job" button → appears in Job mode; opens the auto-created Pending job so staff can assign and progress it
|
||||
- "Customer" button → opens the matched/created customer record
|
||||
- If submission failed (e.g. seed data not run), the session is still marked Submitted but buttons won't appear — raw intake data is still visible so staff can create manually
|
||||
|
||||
**Dashboard Kiosk card:** Shows whether the kiosk is activated. Contains "Start Intake" (triggers in-person session) and "Send Intake Link" (opens email dialog) buttons. Both are disabled if the kiosk is not activated.
|
||||
|
||||
**Troubleshooting:**
|
||||
- "Connection issue — retrying…" on tablet: Wi-Fi problem; dot auto-recovers when connectivity returns
|
||||
- Tablet doesn't respond to Start Intake: waits up to 3 s; reload Welcome page if still stuck
|
||||
- No View Quote/Job button after submission: Seed Data not run — Platform Admin must run it from Platform Management → Seed Data
|
||||
- Signature pad not working: requires capacitive touch (finger or stylus); ensure "Request Desktop Site" is off in browser settings
|
||||
- AI quote times out on mobile: photos are auto-compressed; "Still analyzing…" message appears after 30 s; retry on stronger connection
|
||||
|
||||
---
|
||||
|
||||
## COMMON WORKFLOWS
|
||||
|
||||
**New company first-time setup:**
|
||||
@@ -1279,6 +1334,15 @@ public static class HelpKnowledgeBase
|
||||
**Prospect to customer:**
|
||||
Create Quote for prospect → Quote Approved → Convert Prospect to Customer → Convert Quote to Job
|
||||
|
||||
**Walk-in customer intake (kiosk — Quote mode):**
|
||||
Staff clicks "Start Intake" on Dashboard → tablet navigates to intake form within 3 s → customer fills out 3 steps (contact, job description, terms + signature) → system creates Customer + Draft Quote → "Walk-in Intake Submitted" notification fires → staff reviews at /Kiosk/Intakes → clicks "View Quote" to price and send the quote
|
||||
|
||||
**Walk-in customer intake (kiosk — Job mode):**
|
||||
Same flow as above, but system creates a Pending Job instead of a Quote → staff clicks "View Job" to assign a worker and progress the job through the workflow
|
||||
|
||||
**Remote intake (customer fills out before arriving):**
|
||||
Staff clicks "Send Intake Link" on Dashboard or Intakes page → enters customer email → customer receives link and completes form on their own device → same auto-create flow as in-person; notification reads "Remote Intake Submitted"
|
||||
|
||||
**Walk-in / phone quote (quick estimate):**
|
||||
Click the AI Quick Quote button (dark-blue floating button, bottom-right) → type description → AI returns price estimate → Save as draft under "Walk-In / Phone" → open the quote → reassign the Customer dropdown on Quote Details to the real customer record once you have their info
|
||||
|
||||
|
||||
@@ -50,6 +50,12 @@ public class OnlineUserMiddleware
|
||||
{
|
||||
await _next(context);
|
||||
|
||||
// Skip AJAX/JSON responses — they are not page navigations and would
|
||||
// cause the "current page" to show the polling endpoint (e.g. /InAppNotifications/Recent)
|
||||
// rather than the actual page the user is on.
|
||||
if (context.Response.ContentType?.Contains("application/json", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return;
|
||||
|
||||
// Only track authenticated, non-API, non-asset requests
|
||||
if (!context.User.Identity?.IsAuthenticated ?? true) return;
|
||||
var path = context.Request.Path.Value ?? string.Empty;
|
||||
|
||||
@@ -47,6 +47,8 @@ public class SubscriptionMiddleware
|
||||
"/Billing",
|
||||
"/api/",
|
||||
"/stripe/",
|
||||
"/hubs/",
|
||||
"/Kiosk/",
|
||||
"/Profile/Photo",
|
||||
"/CompanyLogo",
|
||||
"/AccountDataExport"
|
||||
|
||||
@@ -727,6 +727,12 @@ app.UseMiddleware<PowderCoating.Web.Middleware.MustChangePasswordMiddleware>();
|
||||
// Track authenticated user presence (throttled, in-memory)
|
||||
app.UseMiddleware<PowderCoating.Web.Middleware.OnlineUserMiddleware>();
|
||||
|
||||
// Kiosk intake steps use /Kiosk/Intake/{token}/{action} so the token is a path segment
|
||||
app.MapControllerRoute(
|
||||
name: "kiosk_intake",
|
||||
pattern: "Kiosk/Intake/{token}/{action}",
|
||||
defaults: new { controller = "Kiosk" });
|
||||
|
||||
app.MapControllerRoute(
|
||||
name: "default",
|
||||
pattern: "{controller=Home}/{action=Index}/{id?}");
|
||||
|
||||
@@ -72,6 +72,7 @@ public class InAppNotificationService : IInAppNotificationService
|
||||
message = notification.Message,
|
||||
link = notification.Link,
|
||||
notificationType = notification.NotificationType,
|
||||
customerId = notification.CustomerId,
|
||||
createdAt = now.ToString("o")
|
||||
});
|
||||
}
|
||||
|
||||
@@ -232,4 +232,3 @@
|
||||
});
|
||||
</script>
|
||||
}
|
||||
|
||||
|
||||
@@ -109,6 +109,69 @@
|
||||
<span class="fw-semibold">Per-Company Breakdown</span>
|
||||
<span class="text-muted small">@Model.Rows.Count companies total</span>
|
||||
</div>
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var row in Model.Rows)
|
||||
{
|
||||
<div class="mobile-data-card">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%);">
|
||||
<i class="bi bi-robot"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@row.CompanyName @if (!row.IsActive) { <span class="badge bg-secondary ms-1">Inactive</span> }</h6>
|
||||
<small><span class="badge bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle">@row.Plan</span></small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Today</span>
|
||||
<span class="mobile-card-value @(row.Today > 0 ? "fw-semibold" : "text-muted")">
|
||||
@if (row.Today > 0) { @row.Today.ToString("N0") } else { <span>—</span> }
|
||||
</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">30 Days</span>
|
||||
<span class="mobile-card-value @(row.Last30Days > 0 ? "fw-semibold" : "text-muted")">
|
||||
@if (row.Last30Days > 0) { @row.Last30Days.ToString("N0") } else { <span>—</span> }
|
||||
</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">All Time</span>
|
||||
<span class="mobile-card-value @(row.AllTime > 0 ? "" : "text-muted")">
|
||||
@if (row.AllTime > 0) { @row.AllTime.ToString("N0") } else { <span>—</span> }
|
||||
</span>
|
||||
</div>
|
||||
@if (row.TopFeature != null)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Top Feature</span>
|
||||
<span class="mobile-card-value">
|
||||
<i class="bi @FeatureIcon(row.TopFeature) me-1 text-muted"></i>@row.FeatureDisplayName(row.TopFeature)
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Tier</span>
|
||||
<span class="mobile-card-value"><span class="badge @row.TierBadgeClass">@row.UsageTier</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-controller="Companies" asp-action="Details" asp-route-id="@row.CompanyId" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-building me-1"></i>Company
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (!Model.Rows.Any())
|
||||
{
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-robot fs-1 d-block mb-2 opacity-25"></i>
|
||||
No AI usage logged yet.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle" id="aiUsageTable">
|
||||
<thead class="table-light">
|
||||
|
||||
@@ -176,6 +176,60 @@
|
||||
<div class="card-body">
|
||||
@if (Model.Items.Any())
|
||||
{
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var appointment in Model.Items)
|
||||
{
|
||||
<div class="mobile-data-card">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);">
|
||||
<i class="bi bi-calendar-event"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@appointment.Title</h6>
|
||||
<small>@appointment.ScheduledStartTime.ToString("MMM dd, yyyy")<br />@(!appointment.IsAllDay ? $"{appointment.ScheduledStartTime:h:mm tt} – {appointment.ScheduledEndTime:h:mm tt}" : "All Day")</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value">
|
||||
<span class="badge bg-@appointment.StatusColorClass">@appointment.StatusDisplayName</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Type</span>
|
||||
<span class="mobile-card-value">
|
||||
<span class="badge bg-@appointment.TypeColorClass">@appointment.TypeDisplayName</span>
|
||||
</span>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(appointment.CustomerName))
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Customer</span>
|
||||
<span class="mobile-card-value">@appointment.CustomerName</span>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(appointment.AssignedWorkerName))
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Worker</span>
|
||||
<span class="mobile-card-value">@appointment.AssignedWorkerName</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-action="Details" asp-route-id="@appointment.Id" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye me-1"></i>View
|
||||
</a>
|
||||
<a asp-action="Edit" asp-route-id="@appointment.Id" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-pencil me-1"></i>Edit
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead>
|
||||
|
||||
@@ -21,6 +21,64 @@
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var br in Model)
|
||||
{
|
||||
<div class="mobile-data-card">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #14b8a6 0%, #0f766e 100%);">
|
||||
<i class="bi bi-bank"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@br.Account?.Name</h6>
|
||||
<small>Statement: @br.StatementDate.ToString("MMM d, yyyy")</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value">
|
||||
@if (br.Status == BankReconciliationStatus.Completed)
|
||||
{
|
||||
<span class="badge bg-success">Completed</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-warning text-dark">In Progress</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Ending Balance</span>
|
||||
<span class="mobile-card-value fw-semibold">@br.EndingBalance.ToString("C")</span>
|
||||
</div>
|
||||
@if (br.CompletedAt.HasValue)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Completed By</span>
|
||||
<span class="mobile-card-value">@br.CompletedBy</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
@if (br.Status == BankReconciliationStatus.Completed)
|
||||
{
|
||||
<a asp-action="Report" asp-route-id="@br.Id" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-file-earmark-text me-1"></i>Report
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a asp-action="Reconcile" asp-route-id="@br.Id" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-check2-square me-1"></i>Continue
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
|
||||
@@ -60,6 +60,59 @@
|
||||
<div class="card-body p-0">
|
||||
@if (active.Any())
|
||||
{
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var ban in active)
|
||||
{
|
||||
<div class="mobile-data-card">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #dc2626 0%, #991b1b 100%);">
|
||||
<i class="bi bi-slash-circle"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6 class="font-monospace">@ban.IpAddress</h6>
|
||||
<small class="text-muted">@(ban.Reason ?? "No reason given")</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Banned</span>
|
||||
<span class="mobile-card-value">@ban.BannedAt.ToString("MMM d, yyyy HH:mm")</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Expires</span>
|
||||
<span class="mobile-card-value">
|
||||
@if (ban.ExpiresAt.HasValue)
|
||||
{
|
||||
<span class="badge bg-warning text-dark">@ban.ExpiresAt.Value.ToString("MMM d, yyyy")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary">Permanent</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<form asp-action="Lift" asp-route-id="@ban.Id" method="post" class="d-inline"
|
||||
onsubmit="return confirm('Lift the ban on @ban.IpAddress?')">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-sm btn-outline-success">
|
||||
<i class="bi bi-check-circle me-1"></i>Lift
|
||||
</button>
|
||||
</form>
|
||||
<form asp-action="Delete" asp-route-id="@ban.Id" method="post" class="d-inline"
|
||||
onsubmit="return confirm('Delete ban record for @ban.IpAddress?')">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
@@ -130,6 +183,55 @@
|
||||
<h6 class="mb-0 text-muted"><i class="bi bi-clock-history"></i> Lifted / Expired Bans</h6>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var ban in inactive)
|
||||
{
|
||||
<div class="mobile-data-card">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%);">
|
||||
<i class="bi bi-clock-history"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6 class="font-monospace">@ban.IpAddress</h6>
|
||||
<small>
|
||||
@if (!ban.IsActive)
|
||||
{
|
||||
<span class="badge bg-success">Lifted</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary">Expired</span>
|
||||
}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
@if (!string.IsNullOrEmpty(ban.Reason))
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Reason</span>
|
||||
<span class="mobile-card-value text-muted">@ban.Reason</span>
|
||||
</div>
|
||||
}
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Banned</span>
|
||||
<span class="mobile-card-value text-muted">@ban.BannedAt.ToString("MMM d, yyyy")</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<form asp-action="Delete" asp-route-id="@ban.Id" method="post" class="d-inline"
|
||||
onsubmit="return confirm('Delete ban record for @ban.IpAddress?')">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||
<i class="bi bi-trash me-1"></i>Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
ViewData["Title"] = "Edit Bill";
|
||||
ViewData["PageIcon"] = "bi-pencil-square";
|
||||
ViewData["PageHelpTitle"] = "Edit Bill";
|
||||
ViewData["PageHelpContent"] = "Bills can only be edited while in Draft status. Once marked Open, they are locked — Void the bill and recreate it if corrections are needed after confirmation.";
|
||||
ViewData["PageHelpContent"] = "Bills can only be edited while in Draft status. Once marked Open, they are locked — Void the bill and recreate it if corrections are needed after confirmation.";
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-start mb-4">
|
||||
@@ -24,7 +24,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Bill Details"
|
||||
data-bs-content="Vendor: who you're paying. AP Account: the liability account this bill posts to (e.g. Accounts Payable). Bill Date: date on the vendor's invoice. Due Date: when payment is due — drives overdue status. Vendor Invoice #: the vendor's own reference number for reconciliation.">
|
||||
data-bs-content="Vendor: who you're paying. AP Account: the liability account this bill posts to (e.g. Accounts Payable). Bill Date: date on the vendor's invoice. Due Date: when payment is due — drives overdue status. Vendor Invoice #: the vendor's own reference number for reconciliation.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -34,8 +34,8 @@
|
||||
<label asp-for="VendorId" class="form-label fw-medium">Vendor <span class="text-danger">*</span></label>
|
||||
<select asp-for="VendorId" asp-items="ViewBag.Vendors" class="form-select"
|
||||
data-quick-add-url="/Vendors/Create" data-quick-add-title="Add New Vendor">
|
||||
<option value="">— Select Vendor —</option>
|
||||
<option value="__new__">+ Add New Vendor…</option>
|
||||
<option value="">— Select Vendor —</option>
|
||||
<option value="__new__">+ Add New Vendor…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
@@ -87,7 +87,7 @@
|
||||
}
|
||||
<input type="file" name="receiptFile" id="receiptFile" class="form-control"
|
||||
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf" />
|
||||
<div class="form-text">JPG, PNG, GIF, WebP, or PDF — up to 10 MB.</div>
|
||||
<div class="form-text">JPG, PNG, GIF, WebP, or PDF — up to 10 MB.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,7 +100,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Line Items"
|
||||
data-bs-content="Each line maps to an expense account (e.g. Supplies, Materials, Subcontractors). Optionally link a line to a Job to track costs against specific work orders. Qty × Unit Price = Amount. Use multiple lines to split one bill across different expense categories.">
|
||||
data-bs-content="Each line maps to an expense account (e.g. Supplies, Materials, Subcontractors). Optionally link a line to a Job to track costs against specific work orders. Qty × Unit Price = Amount. Use multiple lines to split one bill across different expense categories.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -134,7 +134,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus"
|
||||
data-bs-title="Bill Summary"
|
||||
data-bs-content="Tax % is applied to the line-item subtotal. The resulting Total is the full amount owed to the vendor. Partial payments are allowed — each payment recorded reduces the balance due until the bill is fully paid.">
|
||||
data-bs-content="Tax % is applied to the line-item subtotal. The resulting Total is the full amount owed to the vendor. Partial payments are allowed — each payment recorded reduces the balance due until the bill is fully paid.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -171,7 +171,7 @@
|
||||
<tr class="line-item-row">
|
||||
<td>
|
||||
<select class="form-select form-select-sm account-select" name="LineItems[INDEX].AccountId" required>
|
||||
<option value="">— Account —</option>
|
||||
<option value="">— Account —</option>
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.ExpenseAccounts)
|
||||
{
|
||||
<option value="@item.Value">@item.Text</option>
|
||||
@@ -181,7 +181,7 @@
|
||||
<td><input type="text" class="form-control form-control-sm" name="LineItems[INDEX].Description" placeholder="Description" /></td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm" name="LineItems[INDEX].JobId">
|
||||
<option value="">—</option>
|
||||
<option value="">—</option>
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.Jobs)
|
||||
{
|
||||
<option value="@item.Value">@item.Text</option>
|
||||
|
||||
@@ -26,11 +26,13 @@
|
||||
var totalPages = (int)(ViewBag.TotalPages ?? 1);
|
||||
var totalCount = (int)(ViewBag.TotalCount ?? 0);
|
||||
var impersonatingId = (int?)(ViewBag.ImpersonatingCompanyId);
|
||||
var showChurned = (bool)(ViewBag.ShowChurned ?? false);
|
||||
var churnedCount = (int)(ViewBag.ChurnedCount ?? 0);
|
||||
|
||||
string SortLink(string col)
|
||||
{
|
||||
var dir = (sortColumn == col && sortDirection == "asc") ? "desc" : "asc";
|
||||
return Url.Action("Index", new { searchTerm, sortColumn = col, sortDirection = dir, pageNumber = 1, pageSize })!;
|
||||
return Url.Action("Index", new { searchTerm, sortColumn = col, sortDirection = dir, pageNumber = 1, pageSize, showChurned })!;
|
||||
}
|
||||
|
||||
string SortIcon(string col)
|
||||
@@ -54,6 +56,7 @@
|
||||
<input type="hidden" name="sortColumn" value="@sortColumn" />
|
||||
<input type="hidden" name="sortDirection" value="@sortDirection" />
|
||||
<input type="hidden" name="pageSize" value="@pageSize" />
|
||||
<input type="hidden" name="showChurned" value="@showChurned.ToString().ToLower()" />
|
||||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
@@ -75,6 +78,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (churnedCount > 0 && !showChurned)
|
||||
{
|
||||
<div class="alert alert-secondary alert-permanent d-flex align-items-center gap-2 mb-3 py-2">
|
||||
<i class="bi bi-eye-slash text-muted"></i>
|
||||
<span class="small"><strong>@churnedCount</strong> churned @(churnedCount == 1 ? "account" : "accounts") (expired or canceled 14+ days ago) hidden.</span>
|
||||
<a href="@Url.Action("Index", new { searchTerm, sortColumn, sortDirection, pageNumber = 1, pageSize, showChurned = true })"
|
||||
class="btn btn-sm btn-outline-secondary ms-auto py-0">Show churned</a>
|
||||
</div>
|
||||
}
|
||||
else if (showChurned && churnedCount > 0)
|
||||
{
|
||||
<div class="alert alert-warning alert-permanent d-flex align-items-center gap-2 mb-3 py-2">
|
||||
<i class="bi bi-eye text-warning"></i>
|
||||
<span class="small">Showing all accounts including <strong>@churnedCount</strong> churned.</span>
|
||||
<a href="@Url.Action("Index", new { searchTerm, sortColumn, sortDirection, pageNumber = 1, pageSize, showChurned = false })"
|
||||
class="btn btn-sm btn-outline-secondary ms-auto py-0">Hide churned</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
@if (Model != null && Model.Any())
|
||||
@@ -313,18 +335,18 @@
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
<li class="page-item @(pageNumber == 1 ? "disabled" : "")">
|
||||
<a class="page-link" href="@Url.Action("Index", new { searchTerm, sortColumn, sortDirection, pageNumber = pageNumber - 1, pageSize })">
|
||||
<a class="page-link" href="@Url.Action("Index", new { searchTerm, sortColumn, sortDirection, pageNumber = pageNumber - 1, pageSize, showChurned })">
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</a>
|
||||
</li>
|
||||
@for (int p = Math.Max(1, pageNumber - 2); p <= Math.Min(totalPages, pageNumber + 2); p++)
|
||||
{
|
||||
<li class="page-item @(p == pageNumber ? "active" : "")">
|
||||
<a class="page-link" href="@Url.Action("Index", new { searchTerm, sortColumn, sortDirection, pageNumber = p, pageSize })">@p</a>
|
||||
<a class="page-link" href="@Url.Action("Index", new { searchTerm, sortColumn, sortDirection, pageNumber = p, pageSize, showChurned })">@p</a>
|
||||
</li>
|
||||
}
|
||||
<li class="page-item @(pageNumber == totalPages ? "disabled" : "")">
|
||||
<a class="page-link" href="@Url.Action("Index", new { searchTerm, sortColumn, sortDirection, pageNumber = pageNumber + 1, pageSize })">
|
||||
<a class="page-link" href="@Url.Action("Index", new { searchTerm, sortColumn, sortDirection, pageNumber = pageNumber + 1, pageSize, showChurned })">
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
@@ -464,6 +486,7 @@
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('pageSize', size);
|
||||
url.searchParams.set('pageNumber', '1');
|
||||
url.searchParams.set('showChurned', '@showChurned.ToString().ToLower()');
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
@{
|
||||
ViewData["Title"] = "Company Health";
|
||||
|
||||
var showChurned = (bool)(ViewBag.ShowChurned ?? false);
|
||||
var churnedCount = (int)(ViewBag.ChurnedCount ?? 0);
|
||||
|
||||
string RiskBadge(ChurnRisk r) => r switch {
|
||||
ChurnRisk.Healthy => "bg-success",
|
||||
ChurnRisk.AtRisk => "bg-warning text-dark",
|
||||
@@ -73,6 +76,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Churned account visibility banner *@
|
||||
@if (churnedCount > 0 && !showChurned)
|
||||
{
|
||||
<div class="alert alert-secondary alert-permanent d-flex align-items-center gap-2 mb-3 py-2">
|
||||
<i class="bi bi-eye-slash text-muted"></i>
|
||||
<span class="small"><strong>@churnedCount</strong> churned @(churnedCount == 1 ? "account" : "accounts") (expired or canceled 14+ days ago) hidden from scores and totals.</span>
|
||||
<a href="@Url.Action("Index", new { risk = ViewBag.Risk, search = ViewBag.Search, configIssuesOnly = ViewBag.ConfigIssuesOnly, showChurned = true })"
|
||||
class="btn btn-sm btn-outline-secondary ms-auto py-0">Show churned</a>
|
||||
</div>
|
||||
}
|
||||
else if (showChurned && churnedCount > 0)
|
||||
{
|
||||
<div class="alert alert-warning alert-permanent d-flex align-items-center gap-2 mb-3 py-2">
|
||||
<i class="bi bi-eye text-warning"></i>
|
||||
<span class="small">Showing all accounts including <strong>@churnedCount</strong> churned.</span>
|
||||
<a href="@Url.Action("Index", new { risk = ViewBag.Risk, search = ViewBag.Search, configIssuesOnly = ViewBag.ConfigIssuesOnly, showChurned = false })"
|
||||
class="btn btn-sm btn-outline-secondary ms-auto py-0">Hide churned</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Summary stat cards *@
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-6 col-lg-3">
|
||||
@@ -193,6 +216,7 @@
|
||||
<label class="form-check-label small" for="configOnly">Config issues only</label>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="showChurned" value="@showChurned.ToString().ToLower()" />
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-sm btn-primary">Filter</button>
|
||||
<a asp-action="Index" class="btn btn-sm btn-outline-secondary ms-1">Clear</a>
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
<option value="data-retention">Data Retention</option>
|
||||
<option value="data-lookups">Data Lookups</option>
|
||||
<option value="pdf-templates">PDF Templates</option>
|
||||
<option value="kiosk">Kiosk</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -100,6 +101,11 @@
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="kiosk-tab" data-bs-toggle="tab" data-bs-target="#kiosk" type="button" role="tab">
|
||||
<i class="bi bi-tablet"></i> Kiosk
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Tabs Content -->
|
||||
@@ -1978,6 +1984,67 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Kiosk Tab -->
|
||||
<div class="tab-pane fade" id="kiosk" role="tabpanel">
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-tablet me-2"></i>Customer Intake Kiosk</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
<h6 class="fw-semibold mb-1">Intake Output</h6>
|
||||
<p class="text-muted small mb-3">
|
||||
When a customer completes the intake form, what should be created in the system?
|
||||
</p>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100 border @(Model.Preferences?.KioskIntakeOutput == "Job" ? "" : "border-primary bg-primary-subtle")"
|
||||
id="kioskOutputQuoteCard" style="cursor:pointer;" onclick="selectKioskOutput('Quote')">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<div class="form-check mb-0">
|
||||
<input class="form-check-input" type="radio" name="kioskOutput" id="kioskOutputQuote"
|
||||
value="Quote" @(Model.Preferences?.KioskIntakeOutput != "Job" ? "checked" : "") />
|
||||
</div>
|
||||
<h6 class="mb-0 fw-semibold"><i class="bi bi-file-earmark-text me-1 text-primary"></i>Create a Quote</h6>
|
||||
</div>
|
||||
<p class="text-muted small mb-0">
|
||||
A draft quote is created and reviewed by staff before work begins.
|
||||
Best for shops that price after seeing the parts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100 border @(Model.Preferences?.KioskIntakeOutput == "Job" ? "border-success bg-success-subtle" : "")"
|
||||
id="kioskOutputJobCard" style="cursor:pointer;" onclick="selectKioskOutput('Job')">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<div class="form-check mb-0">
|
||||
<input class="form-check-input" type="radio" name="kioskOutput" id="kioskOutputJob"
|
||||
value="Job" @(Model.Preferences?.KioskIntakeOutput == "Job" ? "checked" : "") />
|
||||
</div>
|
||||
<h6 class="mb-0 fw-semibold"><i class="bi bi-briefcase me-1 text-success"></i>Create a Job</h6>
|
||||
</div>
|
||||
<p class="text-muted small mb-0">
|
||||
A job is created immediately on submission.
|
||||
Best for shops that price on the spot and want the work order ready right away.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-primary" onclick="saveKioskSettings()">
|
||||
<i class="bi bi-floppy me-1"></i> Save Kiosk Settings
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3248,12 +3315,41 @@
|
||||
else showError(data.message);
|
||||
}
|
||||
|
||||
function selectKioskOutput(value) {
|
||||
document.getElementById('kioskOutputQuote').checked = value === 'Quote';
|
||||
document.getElementById('kioskOutputJob').checked = value === 'Job';
|
||||
|
||||
document.getElementById('kioskOutputQuoteCard').classList.toggle('border-primary', value === 'Quote');
|
||||
document.getElementById('kioskOutputQuoteCard').classList.toggle('bg-primary-subtle', value === 'Quote');
|
||||
document.getElementById('kioskOutputJobCard').classList.toggle('border-success', value === 'Job');
|
||||
document.getElementById('kioskOutputJobCard').classList.toggle('bg-success-subtle', value === 'Job');
|
||||
}
|
||||
|
||||
async function saveKioskSettings() {
|
||||
const value = document.querySelector('input[name="kioskOutput"]:checked')?.value ?? 'Quote';
|
||||
const resp = await fetch('/CompanySettings/UpdateKioskSettings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').val()
|
||||
},
|
||||
body: JSON.stringify({ kioskIntakeOutput: value })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) showSuccess(data.message);
|
||||
else showError(data.message);
|
||||
}
|
||||
|
||||
// Auto-open online-payments tab if redirected with ?tab=online-payments
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get('tab') === 'online-payments') {
|
||||
const btn = document.querySelector('[data-bs-target="#online-payments"]');
|
||||
if (btn) new bootstrap.Tab(btn).show();
|
||||
}
|
||||
if (urlParams.get('tab') === 'kiosk') {
|
||||
const btn = document.querySelector('[data-bs-target="#kiosk"]');
|
||||
if (btn) new bootstrap.Tab(btn).show();
|
||||
}
|
||||
</script>
|
||||
|
||||
}
|
||||
|
||||
@@ -101,6 +101,73 @@
|
||||
else
|
||||
{
|
||||
<div class="card">
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var m in Model)
|
||||
{
|
||||
var expired2 = m.ExpiryDate.HasValue && m.ExpiryDate.Value < DateTime.UtcNow
|
||||
&& m.Status != CreditMemoStatus.FullyApplied
|
||||
&& m.Status != CreditMemoStatus.Voided;
|
||||
var (cmBadge, cmLabel) = m.Status switch
|
||||
{
|
||||
CreditMemoStatus.Active => ("bg-success-subtle text-success", "Active"),
|
||||
CreditMemoStatus.PartiallyApplied => ("bg-warning-subtle text-warning", "Partial"),
|
||||
CreditMemoStatus.FullyApplied => ("bg-secondary-subtle text-secondary", "Applied"),
|
||||
CreditMemoStatus.Voided => ("bg-danger-subtle text-danger", "Voided"),
|
||||
_ => ("bg-secondary-subtle text-secondary", m.Status.ToString())
|
||||
};
|
||||
var cmCustomer = string.IsNullOrWhiteSpace(m.Customer?.CompanyName)
|
||||
? $"{m.Customer?.ContactFirstName} {m.Customer?.ContactLastName}".Trim()
|
||||
: m.Customer!.CompanyName;
|
||||
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", new { id = m.Id })'">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);">
|
||||
<i class="bi bi-journal-minus"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@m.MemoNumber</h6>
|
||||
<small>@cmCustomer</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value"><span class="badge @cmBadge">@cmLabel</span></span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Amount</span>
|
||||
<span class="mobile-card-value">@m.Amount.ToString("C")</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Remaining</span>
|
||||
<span class="mobile-card-value @(m.RemainingBalance > 0 && m.Status != CreditMemoStatus.Voided ? "text-success fw-semibold" : "text-muted")">
|
||||
@m.RemainingBalance.ToString("C")
|
||||
</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Issued</span>
|
||||
<span class="mobile-card-value">@m.IssueDate.ToLocalTime().ToString("MM/dd/yy")</span>
|
||||
</div>
|
||||
@if (m.ExpiryDate.HasValue)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Expires</span>
|
||||
<span class="mobile-card-value @(expired2 ? "text-danger fw-semibold" : "")">
|
||||
@m.ExpiryDate.Value.ToLocalTime().ToString("MM/dd/yy")
|
||||
@if (expired2) { <small>(Expired)</small> }
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-action="Details" asp-route-id="@m.Id" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
|
||||
Details
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
|
||||
@@ -173,9 +173,11 @@
|
||||
<i class="bi bi-envelope-slash me-1"></i>Email off
|
||||
</span>
|
||||
}
|
||||
<span id="sms-status-section">
|
||||
@if (Model.NotifyBySms)
|
||||
{
|
||||
<span class="badge bg-success bg-opacity-10 text-success border border-success border-opacity-25">
|
||||
<span class="badge bg-success bg-opacity-10 text-success border border-success border-opacity-25"
|
||||
title="@(Model.SmsConsentedAt.HasValue ? "Consented " + Model.SmsConsentedAt.Value.ToLocalTime().ToString("MM/dd/yyyy") : "")">
|
||||
<i class="bi bi-chat-fill me-1"></i>SMS on
|
||||
</span>
|
||||
}
|
||||
@@ -184,7 +186,22 @@
|
||||
<span class="badge bg-secondary bg-opacity-10 text-secondary border border-secondary border-opacity-25">
|
||||
<i class="bi bi-chat-slash me-1"></i>SMS off
|
||||
</span>
|
||||
<button type="button" id="btnGetSmsConsent"
|
||||
class="badge bg-primary bg-opacity-10 text-primary border border-primary border-opacity-25 border-0"
|
||||
style="cursor:pointer;"
|
||||
title="Send SMS consent form to the front-desk kiosk tablet"
|
||||
onclick="pushSmsConsent(@Model.Id)">
|
||||
<i class="bi bi-chat-dots me-1"></i>Get SMS Consent
|
||||
</button>
|
||||
<button type="button" id="btnCancelSmsConsent"
|
||||
class="badge bg-warning bg-opacity-10 text-warning border border-warning border-opacity-25 border-0 d-none"
|
||||
style="cursor:pointer;"
|
||||
title="Cancel the pending kiosk consent request"
|
||||
onclick="cancelSmsConsent()">
|
||||
<i class="bi bi-x-circle me-1"></i>Cancel Consent
|
||||
</button>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -543,3 +560,8 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/customer-details.js" asp-append-version="true"></script>
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,63 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var a in Model)
|
||||
{
|
||||
var fd = a.AccumulatedDepreciation >= (a.PurchaseCost - a.SalvageValue);
|
||||
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", new { id = a.Id })'">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%);">
|
||||
<i class="bi bi-building-gear"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@a.Name</h6>
|
||||
<small>Purchased @a.PurchaseDate.ToLocalTime().ToString("MM/dd/yyyy")</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value">
|
||||
@if (a.IsDisposed)
|
||||
{
|
||||
<span class="badge bg-secondary">Disposed</span>
|
||||
}
|
||||
else if (fd)
|
||||
{
|
||||
<span class="badge bg-light text-dark border">Fully Depreciated</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-success">Active</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Cost</span>
|
||||
<span class="mobile-card-value">@a.PurchaseCost.ToString("C")</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Book Value</span>
|
||||
<span class="mobile-card-value @(a.BookValue <= 0 ? "text-muted" : "text-success fw-semibold")">
|
||||
@a.BookValue.ToString("C")
|
||||
</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Monthly Depr.</span>
|
||||
<span class="mobile-card-value">@a.MonthlyDepreciation.ToString("C")</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-action="Details" asp-route-id="@a.Id" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
|
||||
<i class="bi bi-eye me-1"></i>View
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
@model PowderCoating.Application.DTOs.GiftCertificate.BulkCreateGiftCertificateDto
|
||||
@using PowderCoating.Core.Enums
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Bulk Create Gift Certificates";
|
||||
ViewData["PageIcon"] = "bi-gift";
|
||||
}
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-7">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-bottom py-3">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-collection me-2 text-primary"></i>Bulk Gift Certificate Generator
|
||||
</h5>
|
||||
<p class="text-muted small mb-0 mt-1">
|
||||
Create a batch of certificates for car shows, events, or promotions. All certificates will have the same
|
||||
face value and be generated with sequential codes ready to print.
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<form asp-action="BulkCreate" method="post">
|
||||
<div asp-validation-summary="ModelOnly" class="alert alert-danger" role="alert"></div>
|
||||
|
||||
<div class="row g-3">
|
||||
|
||||
<div class="col-md-5">
|
||||
<label asp-for="Quantity" class="form-label fw-semibold">
|
||||
<i class="bi bi-123 me-1 text-muted"></i>@Html.DisplayNameFor(m => m.Quantity)
|
||||
</label>
|
||||
<input asp-for="Quantity" type="number" class="form-control form-control-lg"
|
||||
min="1" max="500" placeholder="25" />
|
||||
<span asp-validation-for="Quantity" class="text-danger small"></span>
|
||||
<div class="form-text">Max 500 per batch.</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-7">
|
||||
<label asp-for="Amount" class="form-label fw-semibold">
|
||||
<i class="bi bi-currency-dollar me-1 text-muted"></i>@Html.DisplayNameFor(m => m.Amount)
|
||||
</label>
|
||||
<div class="input-group input-group-lg">
|
||||
<span class="input-group-text">$</span>
|
||||
<input asp-for="Amount" type="number" class="form-control"
|
||||
min="1" max="9999.99" step="0.01" placeholder="50.00" />
|
||||
</div>
|
||||
<span asp-validation-for="Amount" class="text-danger small"></span>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label asp-for="IssuedReason" class="form-label fw-semibold">
|
||||
<i class="bi bi-tag me-1 text-muted"></i>@Html.DisplayNameFor(m => m.IssuedReason)
|
||||
</label>
|
||||
<select asp-for="IssuedReason" class="form-select">
|
||||
@foreach (var reason in Enum.GetValues<GiftCertificateIssuedReason>())
|
||||
{
|
||||
<option value="@reason">@reason</option>
|
||||
}
|
||||
</select>
|
||||
<span asp-validation-for="IssuedReason" class="text-danger small"></span>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label asp-for="ExpiryDate" class="form-label fw-semibold">
|
||||
<i class="bi bi-calendar-x me-1 text-muted"></i>@Html.DisplayNameFor(m => m.ExpiryDate)
|
||||
</label>
|
||||
<input asp-for="ExpiryDate" type="date" class="form-control" />
|
||||
<span asp-validation-for="ExpiryDate" class="text-danger small"></span>
|
||||
<div class="form-text">Leave blank for no expiration.</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label asp-for="Notes" class="form-label fw-semibold">
|
||||
<i class="bi bi-chat-left-text me-1 text-muted"></i>@Html.DisplayNameFor(m => m.Notes)
|
||||
</label>
|
||||
<textarea asp-for="Notes" class="form-control" rows="2"
|
||||
placeholder="e.g. Awarded at the 2026 Summer Car Show — thanks for attending!"></textarea>
|
||||
<span asp-validation-for="Notes" class="text-danger small"></span>
|
||||
<div class="form-text">Printed on every certificate in the batch.</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Preview summary -->
|
||||
<div id="batchPreview" class="alert alert-primary mt-4 mb-0" style="display:none">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
You are about to create <strong id="prevQty"></strong> certificates worth
|
||||
<strong id="prevAmt"></strong> each — total face value
|
||||
<strong id="prevTotal"></strong>.
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mt-4 pt-3 border-top">
|
||||
<a asp-action="Index" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary btn-lg" id="submitBtn">
|
||||
<i class="bi bi-plus-circle me-2"></i>Create Certificates
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/gift-certificate-bulk.js" asp-append-version="true"></script>
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
@model List<PowderCoating.Core.Entities.GiftCertificate>
|
||||
@using PowderCoating.Core.Enums
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Batch Gift Certificates";
|
||||
ViewData["PageIcon"] = "bi-gift";
|
||||
var batchId = Model.FirstOrDefault()?.BatchId ?? Guid.Empty;
|
||||
var count = Model.Count;
|
||||
var amount = Model.FirstOrDefault()?.OriginalAmount ?? 0m;
|
||||
}
|
||||
|
||||
<div class="alert alert-success alert-permanent mb-4">
|
||||
<i class="bi bi-check-circle-fill me-2"></i>
|
||||
<strong>@count gift certificates created</strong> — each worth @amount.ToString("C").
|
||||
Download the PDF below to print the full batch. This page is bookmarkable — you can return here any time to re-download.
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-white border-bottom d-flex justify-content-between align-items-center py-3">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-collection me-2 text-primary"></i>Batch Certificates (@count)
|
||||
<span class="text-muted small fw-normal ms-2 font-monospace">@batchId.ToString("N")[..8]…</span>
|
||||
</h5>
|
||||
<a asp-action="BatchDownloadPdf" asp-route-batchId="@batchId" class="btn btn-primary">
|
||||
<i class="bi bi-file-pdf me-2"></i>Download All as PDF
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var cert in Model)
|
||||
{
|
||||
<div class="mobile-data-card">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #10b981 0%, #059669 100%);">
|
||||
<i class="bi bi-gift"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6 class="font-monospace">@cert.CertificateCode</h6>
|
||||
<small>@cert.OriginalAmount.ToString("C")</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Issued</span>
|
||||
<span class="mobile-card-value">@cert.IssueDate.ToLocalTime().ToString("MMM d, yyyy")</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Expiry</span>
|
||||
<span class="mobile-card-value">
|
||||
@if (cert.ExpiryDate.HasValue) { @cert.ExpiryDate.Value.ToLocalTime().ToString("MMM d, yyyy") } else { <span class="text-muted">—</span> }
|
||||
</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value"><span class="badge bg-success">Active</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-action="Details" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-eye me-1"></i>View
|
||||
</a>
|
||||
<a asp-action="DownloadPdf" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-file-pdf me-1"></i>PDF
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="ps-3">Certificate Code</th>
|
||||
<th>Face Value</th>
|
||||
<th>Issued</th>
|
||||
<th>Expiry</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var cert in Model)
|
||||
{
|
||||
<tr>
|
||||
<td class="ps-3 fw-semibold font-monospace">@cert.CertificateCode</td>
|
||||
<td>@cert.OriginalAmount.ToString("C")</td>
|
||||
<td>@cert.IssueDate.ToLocalTime().ToString("MMM d, yyyy")</td>
|
||||
<td>
|
||||
@(cert.ExpiryDate.HasValue
|
||||
? cert.ExpiryDate.Value.ToLocalTime().ToString("MMM d, yyyy")
|
||||
: "—")
|
||||
</td>
|
||||
<td><span class="badge bg-success">Active</span></td>
|
||||
<td class="text-end">
|
||||
<a asp-action="Details" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-secondary" title="View details">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<a asp-action="DownloadPdf" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-secondary" title="Download single PDF">
|
||||
<i class="bi bi-file-pdf"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-white border-top d-flex justify-content-between align-items-center py-3">
|
||||
<a asp-action="Index" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to Gift Certificates
|
||||
</a>
|
||||
<a asp-action="BatchDownloadPdf" asp-route-batchId="@batchId" class="btn btn-primary">
|
||||
<i class="bi bi-printer me-2"></i>Print Batch PDF (@count pages)
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -28,7 +28,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-@statusClass alert-permanent d-flex align-items-center mb-4">
|
||||
<div class="alert alert-@statusClass d-flex align-items-center mb-4">
|
||||
<i class="bi bi-gift me-2" style="font-size:1.4rem;"></i>
|
||||
<div>
|
||||
<strong>@statusLabel</strong>
|
||||
@@ -38,7 +38,7 @@
|
||||
}
|
||||
@if (Model.ExpiryDate.HasValue)
|
||||
{
|
||||
<span class="ms-2 small">· Expires @Model.ExpiryDate.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MMMM d, yyyy")</span>
|
||||
<span class="ms-2 small">· Expires @Model.ExpiryDate.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MMMM d, yyyy")</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,10 +7,15 @@
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<p class="text-muted mb-0">@ViewBag.TotalActive active certificates — @((ViewBag.TotalValue as decimal? ?? 0m).ToString("C")) outstanding value</p>
|
||||
<a asp-action="Create" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-2"></i>New Certificate
|
||||
</a>
|
||||
<p class="text-muted mb-0">@ViewBag.TotalActive active certificates — @((ViewBag.TotalValue as decimal? ?? 0m).ToString("C")) outstanding value</p>
|
||||
<div class="d-flex gap-2">
|
||||
<a asp-action="BulkCreate" class="btn btn-outline-primary">
|
||||
<i class="bi bi-collection me-2"></i>Bulk Create
|
||||
</a>
|
||||
<a asp-action="Create" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-2"></i>New Certificate
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
@@ -52,6 +57,73 @@ else
|
||||
{
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var cert in Model)
|
||||
{
|
||||
var (gcBadge, gcLabel) = cert.Status switch
|
||||
{
|
||||
GiftCertificateStatus.Active => ("bg-success", "Active"),
|
||||
GiftCertificateStatus.PartiallyRedeemed => ("bg-info text-dark", "Partial"),
|
||||
GiftCertificateStatus.FullyRedeemed => ("bg-secondary", "Used"),
|
||||
GiftCertificateStatus.Expired => ("bg-warning text-dark", "Expired"),
|
||||
GiftCertificateStatus.Voided => ("bg-danger", "Voided"),
|
||||
_ => ("bg-secondary", cert.Status.ToString())
|
||||
};
|
||||
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", new { id = cert.Id })'">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #a855f7 0%, #7c3aed 100%);">
|
||||
<i class="bi bi-gift"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6 class="font-monospace">@cert.CertificateCode</h6>
|
||||
<small>@(cert.RecipientName ?? cert.RecipientEmail ?? "No recipient")</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value"><span class="badge @gcBadge">@gcLabel</span></span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Face Value</span>
|
||||
<span class="mobile-card-value">@cert.OriginalAmount.ToString("C")</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Remaining</span>
|
||||
<span class="mobile-card-value @(cert.RemainingBalance > 0 ? "text-success fw-semibold" : "text-muted")">
|
||||
@cert.RemainingBalance.ToString("C")
|
||||
</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Issued</span>
|
||||
<span class="mobile-card-value">@cert.IssueDate.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd/yy")</span>
|
||||
</div>
|
||||
@if (cert.ExpiryDate.HasValue)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Expires</span>
|
||||
<span class="mobile-card-value @(cert.ExpiryDate.Value < DateTime.Now ? "text-danger" : "")">
|
||||
@cert.ExpiryDate.Value.ToString("MM/dd/yy")
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-action="Details" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
|
||||
<i class="bi bi-eye me-1"></i>View
|
||||
</a>
|
||||
@if (cert.BatchId.HasValue)
|
||||
{
|
||||
<a asp-action="BulkResult" asp-route-batchId="@cert.BatchId" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
|
||||
<i class="bi bi-collection me-1"></i>Batch
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
@@ -75,6 +147,14 @@ else
|
||||
<a asp-action="Details" asp-route-id="@cert.Id" class="fw-semibold text-decoration-none font-monospace">
|
||||
@cert.CertificateCode
|
||||
</a>
|
||||
@if (cert.BatchId.HasValue)
|
||||
{
|
||||
<a asp-action="BulkResult" asp-route-batchId="@cert.BatchId"
|
||||
class="badge bg-primary-subtle text-primary text-decoration-none ms-1"
|
||||
title="View & download batch">
|
||||
<i class="bi bi-collection me-1"></i>Batch
|
||||
</a>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (!string.IsNullOrEmpty(cert.RecipientName))
|
||||
@@ -83,7 +163,7 @@ else
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(cert.RecipientEmail))
|
||||
{
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
@{
|
||||
ViewData["Title"] = "Customer Intake Kiosk";
|
||||
}
|
||||
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<a asp-controller="Help" asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-0">
|
||||
<li class="breadcrumb-item"><a asp-controller="Help" asp-action="Index">Help</a></li>
|
||||
<li class="breadcrumb-item active">Customer Intake Kiosk</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-9">
|
||||
|
||||
<section id="overview" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-info-circle text-primary me-2"></i>Overview
|
||||
</h2>
|
||||
<p>
|
||||
The Customer Intake Kiosk lets walk-in customers fill out their own intake form on a front-desk tablet
|
||||
— no staff assistance required. When they're done, a <strong>customer record</strong> is automatically
|
||||
created (or matched to an existing one), a <strong>Draft Quote or Pending Job</strong> is created
|
||||
depending on your setting, and your team receives an in-app notification.
|
||||
</p>
|
||||
<p>
|
||||
The kiosk runs as a browser page (optimised for iPad and Android tablets) and can also send a
|
||||
<strong>remote link</strong> so customers fill out the form on their own phone before they arrive.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="setup" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-gear text-primary me-2"></i>Setting Up the Kiosk
|
||||
</h2>
|
||||
<ol>
|
||||
<li class="mb-2">
|
||||
Go to <strong>Settings → Kiosk Setup</strong> (or <a href="/Kiosk/Activate">/Kiosk/Activate</a>).
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
Click <strong>Activate Kiosk</strong>. This generates a unique activation token for your company
|
||||
and sets a secure cookie on the current device.
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
On the tablet, open a browser and navigate to <code>/Kiosk/Welcome</code>. You'll see your
|
||||
company logo and a "Ready" indicator — the tablet is now in kiosk mode.
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>Add to Home Screen</strong> on iOS/Android for a full-screen, app-like experience that
|
||||
also preserves camera permissions between sessions.
|
||||
</li>
|
||||
</ol>
|
||||
<div class="alert alert-info alert-permanent">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
The kiosk cookie is device-specific and lasts 365 days. If you swap tablets or clear the browser,
|
||||
go back to Kiosk Setup and activate again.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="starting" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-play-circle text-primary me-2"></i>Starting an Intake Session
|
||||
</h2>
|
||||
<p>There are two ways to start an intake:</p>
|
||||
|
||||
<h3 class="h6 fw-semibold mt-3 mb-2">In-Person (tablet at front desk)</h3>
|
||||
<ol>
|
||||
<li class="mb-1">The tablet sits on the Welcome screen — the customer sees your logo and a "Ready" status dot.</li>
|
||||
<li class="mb-1">A staff member clicks <strong>Start Intake</strong> on the Dashboard (in the Kiosk card).</li>
|
||||
<li class="mb-1">The tablet detects the new session within 3 seconds and automatically navigates to the intake form.</li>
|
||||
<li class="mb-1">The customer fills out <strong>3 steps</strong>: Contact info → Job description → Terms & signature.</li>
|
||||
<li class="mb-1">On Submit, the kiosk shows a thank-you screen and returns to Welcome after 30 seconds.</li>
|
||||
</ol>
|
||||
<div class="alert alert-warning alert-permanent mt-2">
|
||||
<i class="bi bi-clock me-2"></i>
|
||||
If the customer leaves the form untouched for <strong>45 seconds</strong>, it automatically
|
||||
resets to the Welcome screen.
|
||||
</div>
|
||||
|
||||
<h3 class="h6 fw-semibold mt-4 mb-2">Remote Link (customer fills out on their phone)</h3>
|
||||
<ol>
|
||||
<li class="mb-1">Go to <a href="/Kiosk/Intakes">Kiosk → Customer Intakes</a> and click <strong>Send Intake Link</strong>.</li>
|
||||
<li class="mb-1">Or use the <strong>Send Intake Link</strong> button on the Dashboard Kiosk card.</li>
|
||||
<li class="mb-1">Enter the customer's email address and send.</li>
|
||||
<li class="mb-1">The customer receives an email with a secure link and completes the same 3-step form on their own device.</li>
|
||||
<li class="mb-1">Remote sessions don't require a drawn signature — a checkbox agreement is used instead.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section id="output-setting" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-sliders text-primary me-2"></i>Kiosk Output Setting
|
||||
</h2>
|
||||
<p>
|
||||
You can control what gets created when a customer submits the intake form.
|
||||
Go to <a href="/CompanySettings?tab=kiosk">Company Settings → Kiosk</a> and choose:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Create a Quote</strong> (default) — a Draft quote is created for staff to review and price
|
||||
before work begins. The terms shown to the customer will say "subject to a formal quote." Use this
|
||||
if you price after seeing the parts.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Create a Job</strong> — a Pending job is created immediately. The terms will say "a team
|
||||
member will reach out about pricing." Use this if you price on the spot and want the work order
|
||||
ready right away.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="what-happens" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-arrow-right-circle text-primary me-2"></i>What Happens on Submission
|
||||
</h2>
|
||||
<p>When a customer submits their intake form, the system automatically:</p>
|
||||
<ul>
|
||||
<li><strong>Matches or creates a Customer</strong> — searches by email first, then phone. If no match, a new non-commercial customer record is created.</li>
|
||||
<li>
|
||||
<strong>Creates a Draft Quote or Pending Job</strong> — depending on your
|
||||
<a href="/CompanySettings?tab=kiosk">Kiosk Output Setting</a>. Quote mode creates a Draft quote
|
||||
(Normal priority); Job mode creates a Pending job with the customer's description and intake source
|
||||
in Special Instructions.
|
||||
</li>
|
||||
<li><strong>Applies SMS consent</strong> — if the customer opted in, their customer record is updated with <code>NotifyBySms = true</code> and the consent timestamp (TCPA-compliant).</li>
|
||||
<li>
|
||||
<strong>Fires an in-app notification</strong> — your team's notification bell shows
|
||||
"Walk-in Intake Submitted" (or "Remote Intake Submitted" for remote sessions) with a link to
|
||||
the Intakes page.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="reviewing" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-clipboard-check text-primary me-2"></i>Reviewing Submissions (Staff)
|
||||
</h2>
|
||||
<p>
|
||||
Go to <a href="/Kiosk/Intakes">Operations → Intake Sessions</a> to see all sessions.
|
||||
Filter by <strong>Submitted</strong>, <strong>Pending</strong>, or <strong>Expired</strong>.
|
||||
</p>
|
||||
<p>Each row shows:</p>
|
||||
<ul>
|
||||
<li>Customer name, phone, and email</li>
|
||||
<li>Job description snippet</li>
|
||||
<li>Session type (In-Person or Remote) and status badge</li>
|
||||
<li>SMS opt-in indicator</li>
|
||||
<li><strong>View Quote</strong> button — appears when the kiosk is set to Quote mode; opens the auto-created draft quote</li>
|
||||
<li><strong>View Job</strong> button — appears when the kiosk is set to Job mode; opens the auto-created job</li>
|
||||
<li><strong>Customer</strong> button — opens the matched or created customer record</li>
|
||||
</ul>
|
||||
<div class="alert alert-info alert-permanent">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
If submission failed (e.g. a configuration issue), the session is still marked Submitted but the
|
||||
action buttons won't appear. The raw intake data (name, phone, description) is still
|
||||
visible so staff can create the record manually.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="troubleshooting" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-exclamation-triangle text-primary me-2"></i>Troubleshooting
|
||||
</h2>
|
||||
<dl>
|
||||
<dt>Kiosk Welcome screen shows "Connection issue — retrying…"</dt>
|
||||
<dd class="mb-3">The tablet can't reach the server. Check the tablet's Wi-Fi connection. Once connectivity is restored the status dot automatically turns green — no refresh needed.</dd>
|
||||
|
||||
<dt>Kiosk doesn't respond when staff clicks Start Intake</dt>
|
||||
<dd class="mb-3">The tablet polls every 3 seconds. Wait up to 3 seconds after clicking Start Intake. If it still doesn't respond, reload the Welcome page on the tablet. Make sure the tablet is on the same domain as the server (use HTTPS).</dd>
|
||||
|
||||
<dt>The tablet shows the wrong company logo or no logo</dt>
|
||||
<dd class="mb-3">Upload your company logo at Settings → Company Settings → Logo. The kiosk reads your logo directly — no separate kiosk logo setting is needed.</dd>
|
||||
|
||||
<dt>Signature pad doesn't work on the tablet</dt>
|
||||
<dd class="mb-3">Use a capacitive stylus or fingertip — the signature pad requires touch input. Make sure the browser isn't in desktop mode (check "Request Desktop Site" is off). The signature is only required for In-Person sessions.</dd>
|
||||
|
||||
<dt>Submission fails — no job or customer created</dt>
|
||||
<dd class="mb-3">This usually means Seed Data hasn't been run for your company. Ask your administrator to go to Platform Management → Seed Data and run the seed. This creates the required job status and priority lookup rows.</dd>
|
||||
|
||||
<dt>AI quote on the quote wizard times out on mobile</dt>
|
||||
<dd class="mb-3">Photos are automatically compressed before upload. If it still times out, your connection may be slow — the spinner will say "Still analyzing…" if it's taking longer than 30 seconds. Try again on a stronger connection.</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3">
|
||||
@await Html.PartialAsync("_HelpNav")
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-transparent fw-semibold small text-muted text-uppercase">
|
||||
On This Page
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<nav class="nav flex-column small">
|
||||
<a class="nav-link py-1 px-3" href="#overview">Overview</a>
|
||||
<a class="nav-link py-1 px-3" href="#setup">Setting Up the Kiosk</a>
|
||||
<a class="nav-link py-1 px-3" href="#starting">Starting an Intake</a>
|
||||
<a class="nav-link py-1 px-3" href="#output-setting">Kiosk Output Setting</a>
|
||||
<a class="nav-link py-1 px-3" href="#what-happens">What Happens on Submission</a>
|
||||
<a class="nav-link py-1 px-3" href="#reviewing">Reviewing Submissions</a>
|
||||
<a class="nav-link py-1 px-3" href="#troubleshooting">Troubleshooting</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -25,6 +25,10 @@
|
||||
asp-controller="Help" asp-action="Jobs">
|
||||
<i class="bi bi-briefcase"></i> Jobs
|
||||
</a>
|
||||
<a class="nav-link py-2 px-3 d-flex align-items-center gap-2 @(currentAction == "CustomerIntakeKiosk" ? "active fw-semibold text-primary" : "text-body")"
|
||||
asp-controller="Help" asp-action="CustomerIntakeKiosk">
|
||||
<i class="bi bi-tablet"></i> Customer Intake Kiosk
|
||||
</a>
|
||||
<a class="nav-link py-2 px-3 d-flex align-items-center gap-2 @(currentAction == "Quotes" ? "active fw-semibold text-primary" : "text-body")"
|
||||
asp-controller="Help" asp-action="Quotes">
|
||||
<i class="bi bi-file-earmark-text"></i> Quotes
|
||||
|
||||
@@ -29,6 +29,61 @@
|
||||
else
|
||||
{
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var n in items)
|
||||
{
|
||||
bool mIsRead = (bool)n.IsRead;
|
||||
string mTitle = (string)n.Title;
|
||||
string mMessage = (string)n.Message;
|
||||
string? mLink = (string?)n.Link;
|
||||
string mType = (string)n.NotificationType;
|
||||
DateTime mCreatedAt = ((DateTime)n.CreatedAt).Tz(ViewBag.CompanyTimeZone as string);
|
||||
<div class="mobile-data-card notif-history-row @(!mIsRead ? "notif-unread" : "")"
|
||||
data-id="@n.Id"
|
||||
data-title="@mTitle"
|
||||
data-message="@mMessage"
|
||||
data-link="@(mLink ?? "")"
|
||||
data-type="@mType"
|
||||
data-is-read="@(mIsRead ? "1" : "0")"
|
||||
data-created-at="@mCreatedAt.ToString("MMM d, yyyy h:mm tt")">
|
||||
<div class="mobile-card-header" style="@(!mIsRead ? "background:rgba(99,102,241,0.08);" : "")">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);">
|
||||
<i class="bi bi-bell"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6 class="@(!mIsRead ? "fw-semibold" : "text-muted")">
|
||||
@if (!mIsRead)
|
||||
{
|
||||
<span style="display:inline-block;width:8px;height:8px;background:#6366f1;border-radius:50%;margin-right:6px;"></span>
|
||||
}
|
||||
@mTitle
|
||||
</h6>
|
||||
<small>@mCreatedAt.ToString("MMM d, yyyy h:mm tt")</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Type</span>
|
||||
<span class="mobile-card-value"><span class="badge bg-secondary bg-opacity-25 text-body small">@mType</span></span>
|
||||
</div>
|
||||
<div class="mobile-card-row" style="align-items:flex-start;">
|
||||
<span class="mobile-card-label">Message</span>
|
||||
<span class="mobile-card-value" style="white-space:normal;text-align:right;">@mMessage</span>
|
||||
</div>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(mLink))
|
||||
{
|
||||
<div class="mobile-card-footer">
|
||||
<a href="@mLink" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
|
||||
<i class="bi bi-arrow-right me-1"></i>Open
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
|
||||
@@ -126,6 +126,77 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@{ lastMfr = null; }
|
||||
@foreach (var item in needOrder)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(selectedMfr) && item.Manufacturer != lastMfr)
|
||||
{
|
||||
lastMfr = item.Manufacturer;
|
||||
<div class="text-uppercase fw-semibold text-muted small px-2 py-1 border-bottom mt-2">
|
||||
@(string.IsNullOrWhiteSpace(item.Manufacturer) ? "No Manufacturer" : item.Manufacturer)
|
||||
</div>
|
||||
}
|
||||
<div class="mobile-data-card">
|
||||
<div class="mobile-card-header">
|
||||
@if (!string.IsNullOrWhiteSpace(item.ColorCode))
|
||||
{
|
||||
<div class="mobile-card-icon" style="background: @(item.ColorCode.StartsWith("#") ? item.ColorCode : "#" + item.ColorCode); border: 1px solid var(--bs-border-color);"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #64748b 0%, #475569 100%);">
|
||||
<i class="bi bi-palette"></i>
|
||||
</div>
|
||||
}
|
||||
<div class="mobile-card-title">
|
||||
<h6>@(item.ColorName ?? item.Name)</h6>
|
||||
<small>@(item.Manufacturer ?? "No Manufacturer")</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
@if (!string.IsNullOrWhiteSpace(item.ManufacturerPartNumber))
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Part #</span>
|
||||
<span class="mobile-card-value text-muted">@item.ManufacturerPartNumber</span>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(item.Finish))
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Finish</span>
|
||||
<span class="mobile-card-value">@item.Finish</span>
|
||||
</div>
|
||||
}
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">In Stock</span>
|
||||
<span class="mobile-card-value">
|
||||
@if (item.QuantityOnHand > 0)
|
||||
{
|
||||
<span class="badge bg-success bg-opacity-10 text-success">@item.QuantityOnHand.ToString("N2") @item.UnitOfMeasure</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">None</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<button class="btn btn-sm btn-outline-success btn-toggle-panel"
|
||||
data-item-id="@item.Id" data-has-panel="true">
|
||||
<i class="bi bi-check-lg me-1"></i>Got It
|
||||
</button>
|
||||
<a asp-action="Details" asp-route-id="@item.Id" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0" id="needTable">
|
||||
<thead class="table-group-divider">
|
||||
@@ -220,6 +291,68 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@{ lastMfr = null; }
|
||||
@foreach (var item in onHand)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(selectedMfr) && item.Manufacturer != lastMfr)
|
||||
{
|
||||
lastMfr = item.Manufacturer;
|
||||
<div class="text-uppercase fw-semibold text-muted small px-2 py-1 border-bottom mt-2">
|
||||
@(string.IsNullOrWhiteSpace(item.Manufacturer) ? "No Manufacturer" : item.Manufacturer)
|
||||
</div>
|
||||
}
|
||||
<div class="mobile-data-card">
|
||||
<div class="mobile-card-header">
|
||||
@if (!string.IsNullOrWhiteSpace(item.ColorCode))
|
||||
{
|
||||
<div class="mobile-card-icon" style="background: @(item.ColorCode.StartsWith("#") ? item.ColorCode : "#" + item.ColorCode); border: 1px solid var(--bs-border-color);"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #059669 0%, #047857 100%);">
|
||||
<i class="bi bi-palette"></i>
|
||||
</div>
|
||||
}
|
||||
<div class="mobile-card-title">
|
||||
<h6>@(item.ColorName ?? item.Name)</h6>
|
||||
<small>@(item.Manufacturer ?? "No Manufacturer")</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
@if (!string.IsNullOrWhiteSpace(item.ManufacturerPartNumber))
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Part #</span>
|
||||
<span class="mobile-card-value text-muted">@item.ManufacturerPartNumber</span>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(item.Finish))
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Finish</span>
|
||||
<span class="mobile-card-value">@item.Finish</span>
|
||||
</div>
|
||||
}
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value"><span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>On Wall</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<button class="btn btn-sm btn-outline-danger btn-toggle-panel"
|
||||
data-item-id="@item.Id" data-has-panel="false">
|
||||
<i class="bi bi-x-lg me-1"></i>Remove
|
||||
</button>
|
||||
<a asp-action="Details" asp-route-id="@item.Id" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-group-divider">
|
||||
|
||||
@@ -168,6 +168,23 @@
|
||||
}
|
||||
.reason-pill.selected { border-color: var(--purple); background: #f3effe; color: var(--purple); font-weight: 600; }
|
||||
|
||||
/* ── Input mode toggle ───────────────────────── */
|
||||
.mode-toggle { display: flex; border: 1.5px solid var(--border); border-radius: 8px; overflow: hidden; margin-bottom: 18px; }
|
||||
.mode-btn {
|
||||
flex: 1;
|
||||
padding: 10px 8px;
|
||||
background: #fff;
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.mode-btn.active { background: var(--purple); color: #fff; }
|
||||
.mode-btn:first-child { border-right: 1.5px solid var(--border); }
|
||||
|
||||
/* ── Submit / Cancel ─────────────────────────── */
|
||||
.btn-submit {
|
||||
width: 100%;
|
||||
@@ -309,12 +326,28 @@
|
||||
|
||||
<div class="form-card">
|
||||
<h2>2. Enter Quantity</h2>
|
||||
<div class="field">
|
||||
|
||||
<div class="mode-toggle">
|
||||
<button type="button" class="mode-btn active" id="modeUsed" onclick="setMode('used')">Amount Used</button>
|
||||
<button type="button" class="mode-btn" id="modeRemaining" onclick="setMode('remaining')">Remaining Weight</button>
|
||||
</div>
|
||||
|
||||
<!-- amount-used mode -->
|
||||
<div id="usedField" class="field">
|
||||
<label for="quantityInput">Amount Used (@item.UnitOfMeasure) <span class="req">*</span></label>
|
||||
<input type="number" id="quantityInput" name="quantity"
|
||||
min="0" step="any" required placeholder="0" inputmode="decimal" />
|
||||
min="0" step="any" placeholder="0" inputmode="decimal"
|
||||
oninvalid="this.setCustomValidity('')" />
|
||||
<div class="hint" id="balanceHint"></div>
|
||||
</div>
|
||||
|
||||
<!-- remaining-weight mode -->
|
||||
<div id="remainingField" class="field" style="display:none">
|
||||
<label for="remainingInput">Weight Remaining (@item.UnitOfMeasure) <span class="req">*</span></label>
|
||||
<input type="number" id="remainingInput" min="0" step="any"
|
||||
placeholder="0" inputmode="decimal" />
|
||||
<div class="hint" id="remainingHint"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-card">
|
||||
@@ -346,6 +379,21 @@
|
||||
<script>
|
||||
var currentQty = @item.QuantityOnHand;
|
||||
var uom = '@item.UnitOfMeasure';
|
||||
var inputMode = 'used'; // 'used' | 'remaining'
|
||||
|
||||
// ── Input mode toggle ────────────────────────────
|
||||
function setMode(mode) {
|
||||
inputMode = mode;
|
||||
document.getElementById('modeUsed').classList.toggle('active', mode === 'used');
|
||||
document.getElementById('modeRemaining').classList.toggle('active', mode === 'remaining');
|
||||
document.getElementById('usedField').style.display = mode === 'used' ? '' : 'none';
|
||||
document.getElementById('remainingField').style.display = mode === 'remaining' ? '' : 'none';
|
||||
document.getElementById('balanceHint').textContent = '';
|
||||
document.getElementById('remainingHint').textContent = '';
|
||||
// clear both inputs when switching
|
||||
document.getElementById('quantityInput').value = '';
|
||||
document.getElementById('remainingInput').value = '';
|
||||
}
|
||||
|
||||
// ── Job selection ────────────────────────────────
|
||||
function showTab(tab) {
|
||||
@@ -384,7 +432,7 @@
|
||||
document.getElementById('transactionTypeInput').value = el.dataset.val;
|
||||
}
|
||||
|
||||
// ── Balance hint ─────────────────────────────────
|
||||
// ── Balance hint (amount-used mode) ─────────────
|
||||
document.getElementById('quantityInput').addEventListener('input', function() {
|
||||
var qty = parseFloat(this.value) || 0;
|
||||
if (!this.value) { document.getElementById('balanceHint').textContent = ''; return; }
|
||||
@@ -394,6 +442,24 @@
|
||||
'New balance: <strong style="color:' + col + '">' + newBal.toFixed(2) + ' ' + uom + '</strong>';
|
||||
});
|
||||
|
||||
// ── Remaining-weight hint ────────────────────────
|
||||
document.getElementById('remainingInput').addEventListener('input', function() {
|
||||
var hint = document.getElementById('remainingHint');
|
||||
if (!this.value) { hint.textContent = ''; return; }
|
||||
var remaining = parseFloat(this.value);
|
||||
if (isNaN(remaining) || remaining < 0) { hint.innerHTML = '<span style="color:var(--danger)">Enter a valid weight.</span>'; return; }
|
||||
if (remaining > currentQty) {
|
||||
hint.innerHTML = '<span style="color:var(--danger)">Remaining cannot exceed current stock (' + currentQty.toFixed(2) + ' ' + uom + ').</span>';
|
||||
return;
|
||||
}
|
||||
var used = currentQty - remaining;
|
||||
if (used <= 0) {
|
||||
hint.innerHTML = '<span style="color:var(--danger)">No usage to log — remaining equals current stock.</span>';
|
||||
return;
|
||||
}
|
||||
hint.innerHTML = 'Will log <strong>' + used.toFixed(2) + ' ' + uom + '</strong> as used — new balance: <strong style="color:' + (remaining === 0 ? '#343a40' : 'var(--success)') + '">' + remaining.toFixed(2) + ' ' + uom + '</strong>';
|
||||
});
|
||||
|
||||
// ── Preselect job if coming from success page ────
|
||||
@if (preselectedJobId.HasValue)
|
||||
{
|
||||
@@ -406,8 +472,37 @@
|
||||
</text>
|
||||
}
|
||||
|
||||
// ── Submit spinner ───────────────────────────────
|
||||
document.getElementById('usageForm').addEventListener('submit', function() {
|
||||
// ── Submit: resolve quantity from whichever mode is active ──
|
||||
document.getElementById('usageForm').addEventListener('submit', function(e) {
|
||||
if (inputMode === 'remaining') {
|
||||
var remaining = parseFloat(document.getElementById('remainingInput').value);
|
||||
if (isNaN(remaining) || remaining < 0 || remaining > currentQty) {
|
||||
e.preventDefault();
|
||||
document.getElementById('remainingHint').innerHTML =
|
||||
'<span style="color:var(--danger)">Please enter a valid remaining weight.</span>';
|
||||
return;
|
||||
}
|
||||
var used = currentQty - remaining;
|
||||
if (used <= 0) {
|
||||
e.preventDefault();
|
||||
document.getElementById('remainingHint').innerHTML =
|
||||
'<span style="color:var(--danger)">No usage to log — remaining equals current stock.</span>';
|
||||
return;
|
||||
}
|
||||
document.getElementById('quantityInput').value = used.toFixed(4);
|
||||
}
|
||||
|
||||
// validate amount-used mode
|
||||
if (inputMode === 'used') {
|
||||
var qty = parseFloat(document.getElementById('quantityInput').value);
|
||||
if (isNaN(qty) || qty <= 0) {
|
||||
e.preventDefault();
|
||||
document.getElementById('balanceHint').innerHTML =
|
||||
'<span style="color:var(--danger)">Please enter a quantity greater than zero.</span>';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var btn = document.getElementById('submitBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Saving…';
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Invoice Details"
|
||||
data-bs-content="Invoice Date is the date of issue and the reference for payment terms. Due Date drives overdue status and A/R aging. Payment Terms prints on the invoice — changing it here only affects this invoice. Draft, Sent, and Overdue invoices can be edited; Paid and Partially Paid invoices are locked.">
|
||||
data-bs-content="Invoice Date is the date of issue and the reference for payment terms. Due Date drives overdue status and A/R aging. Payment Terms prints on the invoice — changing it here only affects this invoice. Draft, Sent, and Overdue invoices can be edited; Paid and Partially Paid invoices are locked.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -79,7 +79,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Line Items"
|
||||
data-bs-content="Each row is a billable line on the invoice. Qty × Unit Price = Total per line; you can also override Total directly. Color is optional and appears under the description when printed. Add manual lines for charges not in the original job (e.g., rush fee, pickup charge).">
|
||||
data-bs-content="Each row is a billable line on the invoice. Qty × Unit Price = Total per line; you can also override Total directly. Color is optional and appears under the description when printed. Add manual lines for charges not in the original job (e.g., rush fee, pickup charge).">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -163,7 +163,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Notes"
|
||||
data-bs-content="Customer Notes appear on the printed and emailed invoice — use these for payment instructions, thank-you messages, or job-specific reminders. Internal Notes are only visible to staff in the app and are never sent to the customer.">
|
||||
data-bs-content="Customer Notes appear on the printed and emailed invoice — use these for payment instructions, thank-you messages, or job-specific reminders. Internal Notes are only visible to staff in the app and are never sent to the customer.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
</td>
|
||||
<td>@inv.InvoiceDate.ToString("MM/dd/yyyy")</td>
|
||||
<td class="@(inv.IsOverdue ? "fw-bold text-danger" : "")">
|
||||
@(inv.DueDate.HasValue ? inv.DueDate.Value.ToString("MM/dd/yyyy") : "—")
|
||||
@(inv.DueDate.HasValue ? inv.DueDate.Value.ToString("MM/dd/yyyy") : "—")
|
||||
</td>
|
||||
<td class="text-end">@inv.Total.ToString("C")</td>
|
||||
<td class="text-end @(inv.BalanceDue > 0 ? "fw-semibold" : "text-muted")">
|
||||
@@ -167,6 +167,77 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var inv in Model.Items)
|
||||
{
|
||||
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", "Invoices", new { id = inv.Id })'">
|
||||
<div class="mobile-card-header" style="@(inv.IsOverdue ? "background:#fee2e2;" : "")">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);">
|
||||
<i class="bi bi-receipt"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@inv.InvoiceNumber</h6>
|
||||
<small>@inv.CustomerName</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value">
|
||||
@await Html.PartialAsync("_StatusChip", (Kind: StatusChipHelper.InvoiceStatus(inv.Status), Text: InvoicesController.GetStatusDisplay(inv.Status)))
|
||||
</span>
|
||||
</div>
|
||||
@if (inv.JobId.HasValue)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Job</span>
|
||||
<span class="mobile-card-value">
|
||||
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@inv.JobId"
|
||||
class="text-decoration-none" onclick="event.stopPropagation()">
|
||||
@inv.JobNumber
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Date</span>
|
||||
<span class="mobile-card-value">@inv.InvoiceDate.ToString("MM/dd/yy")</span>
|
||||
</div>
|
||||
@if (inv.DueDate.HasValue)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Due</span>
|
||||
<span class="mobile-card-value @(inv.IsOverdue ? "fw-bold text-danger" : "")">
|
||||
@inv.DueDate.Value.ToString("MM/dd/yy")
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Total</span>
|
||||
<span class="mobile-card-value">@inv.Total.ToString("C")</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Balance Due</span>
|
||||
<span class="mobile-card-value @(inv.BalanceDue > 0 ? "fw-semibold" : "text-muted")">
|
||||
@inv.BalanceDue.ToString("C")
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-action="Details" asp-route-id="@inv.Id"
|
||||
class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
|
||||
<i class="bi bi-eye me-1"></i>View
|
||||
</a>
|
||||
<a asp-action="DownloadPdf" asp-route-id="@inv.Id"
|
||||
class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
|
||||
<i class="bi bi-file-pdf me-1"></i>PDF
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-3">
|
||||
@await Html.PartialAsync("_Pagination", Model)
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model PowderCoating.Application.DTOs.Job.CreateJobDto
|
||||
@model PowderCoating.Application.DTOs.Job.CreateJobDto
|
||||
@using PowderCoating.Core.Entities
|
||||
|
||||
@{
|
||||
@@ -313,96 +313,8 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Surface Area Calculator Modal -->
|
||||
<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 (@(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>
|
||||
@await Html.PartialAsync("_SqFtCalculatorModal")
|
||||
@await Html.PartialAsync("_ItemWizardModal")
|
||||
|
||||
<!-- Embedded data for JS -->
|
||||
@if (ViewBag.InventoryCoatings != null)
|
||||
@@ -489,41 +401,7 @@
|
||||
|
||||
@section Styles {
|
||||
<link rel="stylesheet" href="~/lib/tom-select/css/tom-select.bootstrap5.min.css">
|
||||
<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; }
|
||||
[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>
|
||||
<link rel="stylesheet" href="~/css/item-wizard.css">
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model PowderCoating.Application.DTOs.Job.JobDto
|
||||
@model PowderCoating.Application.DTOs.Job.JobDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = $"Job {Model.JobNumber}";
|
||||
@@ -57,7 +57,7 @@
|
||||
}
|
||||
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 class="d-flex gap-2 flex-wrap">
|
||||
@@ -217,7 +217,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
@@ -263,7 +263,7 @@
|
||||
<i class="bi bi-x-circle me-1"></i><small>Clear date</small>
|
||||
</button>
|
||||
<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>
|
||||
@@ -273,7 +273,7 @@
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<select id="workerAssignmentSelect" class="form-select form-select-sm"
|
||||
onchange="updateWorkerAssignment(this)">
|
||||
<option value="">� Unassigned �</option>
|
||||
<option value="">– Unassigned –</option>
|
||||
@foreach (var w in (IEnumerable<SelectListItem>)ViewBag.Workers)
|
||||
{
|
||||
if (w.Value == Model.AssignedUserId)
|
||||
@@ -287,7 +287,7 @@
|
||||
}
|
||||
</select>
|
||||
<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 id="workerSavedTick" class="text-success small d-none">
|
||||
<i class="bi bi-check-circle-fill"></i>
|
||||
@@ -321,7 +321,7 @@
|
||||
|
||||
<div class="card-body">
|
||||
|
||||
@* ── Catalog Products ── *@
|
||||
@* -- Catalog Products -- *@
|
||||
@if (catalogItems.Any())
|
||||
{
|
||||
<h6 class="text-primary mb-3"><i class="bi bi-bag-check me-2"></i>Catalog Products</h6>
|
||||
@@ -351,10 +351,10 @@
|
||||
{
|
||||
<br />
|
||||
<small class="ms-3">
|
||||
� <strong>@coat.CoatName</strong>
|
||||
• <strong>@coat.CoatName</strong>
|
||||
@if (!string.IsNullOrEmpty(coat.ColorName))
|
||||
{
|
||||
<text> � @coat.ColorName</text>
|
||||
<text> – @coat.ColorName</text>
|
||||
@if (!string.IsNullOrEmpty(coat.VendorName))
|
||||
{
|
||||
<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>
|
||||
@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))
|
||||
@@ -390,7 +390,7 @@
|
||||
@foreach (var ps in item.PrepServices)
|
||||
{
|
||||
<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))
|
||||
@@ -414,7 +414,7 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── Custom Work ── *@
|
||||
@* -- Custom Work -- *@
|
||||
@if (customItems.Any())
|
||||
{
|
||||
<h6 class="text-success mb-3"><i class="bi bi-calculator me-2"></i>Custom Work</h6>
|
||||
@@ -478,10 +478,10 @@
|
||||
{
|
||||
<br />
|
||||
<small class="ms-3">
|
||||
� <strong>@coat.CoatName</strong>
|
||||
• <strong>@coat.CoatName</strong>
|
||||
@if (!string.IsNullOrEmpty(coat.ColorName))
|
||||
{
|
||||
<text> � @coat.ColorName</text>
|
||||
<text> – @coat.ColorName</text>
|
||||
@if (!string.IsNullOrEmpty(coat.VendorName))
|
||||
{
|
||||
<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>
|
||||
@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))
|
||||
@@ -517,7 +517,7 @@
|
||||
@foreach (var ps in item.PrepServices)
|
||||
{
|
||||
<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))
|
||||
@@ -532,7 +532,7 @@
|
||||
<text>@item.SurfaceAreaSqFt.ToString("F2") @ViewBag.AreaUnit</text>
|
||||
<br /><small class="text-muted">per item</small>
|
||||
}
|
||||
else { <span class="text-muted">�</span> }
|
||||
else { <span class="text-muted">—</span> }
|
||||
</td>
|
||||
<td class="text-center">
|
||||
@if (item.EstimatedMinutes > 0)
|
||||
@@ -540,7 +540,7 @@
|
||||
<text>@item.EstimatedMinutes min</text>
|
||||
<br /><small class="text-muted">per item</small>
|
||||
}
|
||||
else { <span class="text-muted">�</span> }
|
||||
else { <span class="text-muted">—</span> }
|
||||
</td>
|
||||
<td class="text-center">
|
||||
@if (totalPowderNeeded > 0)
|
||||
@@ -548,7 +548,7 @@
|
||||
<strong class="text-success">@totalPowderNeeded.ToString("F2") lbs</strong>
|
||||
<br /><small class="text-muted">total batch</small>
|
||||
}
|
||||
else { <span class="text-muted">�</span> }
|
||||
else { <span class="text-muted">—</span> }
|
||||
</td>
|
||||
<td class="text-end">@item.UnitPrice.ToString("C")</td>
|
||||
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
|
||||
@@ -565,7 +565,7 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── Labor ── *@
|
||||
@* -- Labor -- *@
|
||||
@if (laborItems.Any())
|
||||
{
|
||||
<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>
|
||||
}
|
||||
else { <span class="text-muted">�</span> }
|
||||
else { <span class="text-muted">—</span> }
|
||||
</td>
|
||||
<td class="text-end">@item.UnitPrice.ToString("C")</td>
|
||||
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
|
||||
@@ -616,7 +616,7 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── Mobile cards ── *@
|
||||
@* -- Mobile cards -- *@
|
||||
<div class="d-lg-none mt-2">
|
||||
@foreach (var item in Model.Items)
|
||||
{
|
||||
@@ -653,7 +653,7 @@
|
||||
<span class="mobile-card-value">
|
||||
@foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
|
||||
{
|
||||
<small class="d-block">@coat.CoatName@(!string.IsNullOrEmpty(coat.ColorName) ? $" � {coat.ColorName}" : "")</small>
|
||||
<small class="d-block">@coat.CoatName@if (!string.IsNullOrEmpty(coat.ColorName)) { <text> – @coat.ColorName</text> }</small>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
@@ -704,7 +704,7 @@
|
||||
<i class="bi bi-chevron-down collapse-chevron ms-1" style="transition:transform .2s;"></i>
|
||||
</div>
|
||||
<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 estimatedHrs = estimatedMins / 60m;
|
||||
@@ -741,7 +741,7 @@
|
||||
<tfoot class="table-light fw-semibold">
|
||||
<tr>
|
||||
<td colspan="3">Total</td>
|
||||
<td class="text-end" id="timeEntriesTotalHours">�</td>
|
||||
<td class="text-end" id="timeEntriesTotalHours">—</td>
|
||||
<td colspan="3"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
@@ -1099,7 +1099,7 @@
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<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>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
@@ -1117,7 +1117,7 @@
|
||||
value="@(Model.IntakePartCount.HasValue ? Model.IntakePartCount.Value.ToString() : "")"
|
||||
placeholder="@intakeExpectedCount" />
|
||||
<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 class="mb-3">
|
||||
@@ -1310,7 +1310,7 @@
|
||||
<a asp-action="Intake" asp-route-id="@Model.Id"
|
||||
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")">
|
||||
<i class=�bi bi-box-seam me-2�></i>@(Model.IntakeDate.HasValue ? "Intake ?" : "Intake")
|
||||
<i class="bi bi-box-seam me-2"></i>@Html.Raw(Model.IntakeDate.HasValue ? "Intake ✓" : "Intake")
|
||||
</a>
|
||||
}
|
||||
@{
|
||||
@@ -1368,7 +1368,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pricing Summary (internal � d-print-none) -->
|
||||
<!-- Pricing Summary (internal - d-print-none) -->
|
||||
@{
|
||||
var jobPb = ViewBag.JobPricingBreakdown as PowderCoating.Application.DTOs.Quote.QuotePricingBreakdownDto;
|
||||
}
|
||||
@@ -1400,7 +1400,7 @@
|
||||
@if (jobPb.OvenBatchCost > 0)
|
||||
{
|
||||
<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>
|
||||
</div>
|
||||
}
|
||||
@@ -1518,7 +1518,7 @@
|
||||
}
|
||||
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
|
||||
{
|
||||
@@ -1547,7 +1547,7 @@
|
||||
@if (jobPb.FacilityOverheadCost > 0)
|
||||
{
|
||||
<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>
|
||||
</div>
|
||||
}
|
||||
@@ -1712,11 +1712,11 @@
|
||||
<div class="px-3 pt-3 pb-2">
|
||||
<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="fw-semibold" id="costingRevenue">�</span>
|
||||
<span class="fw-semibold" id="costingRevenue">—</span>
|
||||
</div>
|
||||
<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 id="costingPowder">�</span>
|
||||
<span id="costingPowder">—</span>
|
||||
</div>
|
||||
<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;">
|
||||
@@ -1725,7 +1725,7 @@
|
||||
</div>
|
||||
<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 id="costingLabor">�</span>
|
||||
<span id="costingLabor">—</span>
|
||||
</div>
|
||||
<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;">
|
||||
@@ -1734,12 +1734,12 @@
|
||||
</div>
|
||||
<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 id="costingOven">�</span>
|
||||
<span id="costingOven">—</span>
|
||||
</div>
|
||||
<div id="costingReworkSection" style="display:none;">
|
||||
<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 id="costingRework">�</span>
|
||||
<span id="costingRework">—</span>
|
||||
</div>
|
||||
<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;">
|
||||
@@ -1748,25 +1748,25 @@
|
||||
</div>
|
||||
<div class="d-flex justify-content-between small text-success mb-1 ps-2">
|
||||
<span>Billed to Customer</span>
|
||||
<span id="costingReworkBilled">�</span>
|
||||
<span id="costingReworkBilled">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-2" />
|
||||
<div class="d-flex justify-content-between small mb-1 ps-2">
|
||||
<span class="text-muted">Total Costs</span>
|
||||
<span id="costingTotal" class="text-danger">�</span>
|
||||
<span id="costingTotal" class="text-danger">—</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between fw-bold mb-1">
|
||||
<span>Gross Profit</span>
|
||||
<span id="costingProfit">�</span>
|
||||
<span id="costingProfit">—</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between small text-muted mb-1">
|
||||
<span>Gross Margin</span>
|
||||
<span id="costingMargin">�</span>
|
||||
<span id="costingMargin">—</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between small text-muted">
|
||||
<span>Margin vs Quote</span>
|
||||
<span id="costingQuotedMargin">�</span>
|
||||
<span id="costingQuotedMargin">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="costingNotes" class="px-3 pb-3" style="font-size:0.75rem;"></div>
|
||||
@@ -1869,7 +1869,7 @@
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<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>
|
||||
<input type="hidden" id="photoTagsHidden" name="tags" />
|
||||
<div id="photoTagsContainer"></div>
|
||||
@@ -1948,7 +1948,7 @@
|
||||
<textarea class="form-control" id="editPhotoCaption" rows="2" placeholder="Add a description or note..."></textarea>
|
||||
</div>
|
||||
<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" />
|
||||
<div id="editPhotoTagsContainer"></div>
|
||||
</div>
|
||||
@@ -2000,7 +2000,7 @@
|
||||
<div class="mb-2">
|
||||
<label class="form-label fw-semibold" for="smsMessageText">Message</label>
|
||||
<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 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.
|
||||
@@ -2012,7 +2012,7 @@
|
||||
</div>
|
||||
<div class="modal-footer justify-content-between">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal" id="smsDismissBtn">
|
||||
Skip � don't send
|
||||
Skip — don't send
|
||||
</button>
|
||||
<button type="button" class="btn btn-info text-white" id="smsSendBtn">
|
||||
<i class="bi bi-send me-1"></i>Send SMS
|
||||
@@ -2068,98 +2068,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Surface Area Calculator Modal -->
|
||||
<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 (@(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>
|
||||
@await Html.PartialAsync("_SqFtCalculatorModal")
|
||||
@await Html.PartialAsync("_ItemWizardModal")
|
||||
|
||||
<!-- Embedded data for wizard JS -->
|
||||
@if (ViewBag.InventoryCoatings != null)
|
||||
@@ -2223,7 +2133,7 @@
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Specific Item (optional)</label>
|
||||
<select class="form-select" id="rwJobItem">
|
||||
<option value="">� Whole Job �</option>
|
||||
<option value="">– Whole Job –</option>
|
||||
@if (Model.Items != null)
|
||||
{
|
||||
@foreach (var item in Model.Items)
|
||||
@@ -2285,9 +2195,9 @@
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Resolution</label>
|
||||
<select class="form-select" id="rwResolution">
|
||||
<option value="">� Pending �</option>
|
||||
<option value="0">Recoated � No Charge</option>
|
||||
<option value="1">Recoated � Billed to Customer</option>
|
||||
<option value="">– Pending –</option>
|
||||
<option value="0">Recoated — No Charge</option>
|
||||
<option value="1">Recoated — Billed to Customer</option>
|
||||
<option value="2">Customer Credited</option>
|
||||
<option value="3">Written Off</option>
|
||||
<option value="4">No Action Required</option>
|
||||
@@ -2346,7 +2256,7 @@
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Worker <span class="text-danger">*</span></label>
|
||||
<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> ?? []))
|
||||
{
|
||||
<option value="@w.Id">@w.Name</option>
|
||||
@@ -2365,7 +2275,7 @@
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<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">
|
||||
<option value="Sandblasting"></option>
|
||||
<option value="Masking & Taping"></option>
|
||||
@@ -2380,7 +2290,7 @@
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<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 class="text-danger small d-none" id="teError"></div>
|
||||
</div>
|
||||
@@ -2413,12 +2323,16 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
@section Styles {
|
||||
<link rel="stylesheet" href="~/css/item-wizard.css">
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<link rel="stylesheet" href="~/css/job-photos.css" />
|
||||
<script src="~/js/job-photos.js" asp-append-version="true"></script>
|
||||
<script src="~/js/customer-change.js" asp-append-version="true"></script>
|
||||
<script>
|
||||
// ── Inline date editing ──────────────────────────────────────────────
|
||||
// -- Inline date editing ----------------------------------------------
|
||||
const jobId = @Model.Id;
|
||||
const antiForgeryToken = () => document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
|
||||
@@ -2513,38 +2427,13 @@
|
||||
}
|
||||
}
|
||||
</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>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
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;
|
||||
|
||||
// Wrap wizardSave to set a flag before the modal hides
|
||||
@@ -2562,12 +2451,12 @@
|
||||
}
|
||||
});
|
||||
|
||||
// ── Delete confirmation modal ─────────────────────────────────────
|
||||
// -- Delete confirmation modal -------------------------------------
|
||||
let pendingDeleteItemId = -1;
|
||||
const deleteModal = new bootstrap.Modal(document.getElementById('deleteConfirmModal'));
|
||||
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) {
|
||||
const btn = e.target.closest('[data-delete-id]');
|
||||
if (!btn) return;
|
||||
@@ -2600,7 +2489,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- ── Rework / Warranty ────────────────────────────────────────────── -->
|
||||
<!-- -- Rework / Warranty ---------------------------------------------- -->
|
||||
<script>
|
||||
const rework = (() => {
|
||||
const jid = @Model.Id;
|
||||
@@ -2645,12 +2534,12 @@
|
||||
</div>
|
||||
<div class="small mt-1 text-muted">${r.defectDescription}</div>
|
||||
<div class="small text-muted mt-1">
|
||||
Found: ${r.discoveredByDisplay} � ${new Date(r.discoveredDate).toLocaleDateString()}
|
||||
${r.reportedByName ? '� ' + r.reportedByName : ''}
|
||||
Found: ${r.discoveredByDisplay} — ${new Date(r.discoveredDate).toLocaleDateString()}
|
||||
${r.reportedByName ? '– ' + r.reportedByName : ''}
|
||||
${r.jobItemDescription ? ' | Item: ' + r.jobItemDescription : ''}
|
||||
</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('');
|
||||
}
|
||||
|
||||
@@ -2756,7 +2645,7 @@
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- ── Job Costing ──────────────────────────────────────────────────── -->
|
||||
<!-- -- Job Costing ---------------------------------------------------- -->
|
||||
<script>
|
||||
const costing = (() => {
|
||||
const jid = @Model.Id;
|
||||
@@ -2796,7 +2685,7 @@
|
||||
document.getElementById('costingReworkBilled').textContent = fmt(d.reworkBilledToCustomer);
|
||||
const rBody = document.getElementById('reworkCostLines');
|
||||
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 fw-semibold">${fmt(l.cost)}</td></tr>`).join('');
|
||||
} else {
|
||||
@@ -2812,14 +2701,14 @@
|
||||
|
||||
document.getElementById('costingMargin').textContent = `${d.grossMargin}%`;
|
||||
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
|
||||
const pBody = document.getElementById('powderLines');
|
||||
pBody.innerHTML = d.hasPowderData
|
||||
? 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-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('')
|
||||
: '<tr><td colspan="3" class="text-muted">No powder cost data on coats.</td></tr>';
|
||||
|
||||
@@ -2827,14 +2716,14 @@
|
||||
const lBody = document.getElementById('laborLines');
|
||||
lBody.innerHTML = d.hasLaborData
|
||||
? d.laborLines.map(l => `<tr>
|
||||
<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-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 fw-semibold">${fmt(l.total)}</td></tr>`).join('')
|
||||
: '<tr><td colspan="3" class="text-muted">No time entries logged yet.</td></tr>';
|
||||
|
||||
// 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.');
|
||||
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.');
|
||||
@@ -2865,7 +2754,7 @@
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- ── Time Tracking ─────────────────────────────────────────────────── -->
|
||||
<!-- -- Time Tracking --------------------------------------------------- -->
|
||||
<script>
|
||||
const timeTracking = (() => {
|
||||
const jid = @Model.Id;
|
||||
@@ -2873,7 +2762,7 @@
|
||||
const modal = new bootstrap.Modal(document.getElementById('timeEntryModal'));
|
||||
let entries = [];
|
||||
|
||||
// ── Load ──────────────────────────────────────────────────────────
|
||||
// -- Load ----------------------------------------------------------
|
||||
async function load() {
|
||||
const r = await fetch(`/Jobs/GetTimeEntries?jobId=${jid}`);
|
||||
entries = await r.json();
|
||||
@@ -2904,7 +2793,7 @@
|
||||
<td class="fw-semibold">${esc(e.workerName)}</td>
|
||||
<td class="small">${d}</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="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>
|
||||
@@ -2916,12 +2805,12 @@
|
||||
}
|
||||
|
||||
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('timeEntriesTotalHours').textContent = total > 0 ? total.toFixed(2) : '�';
|
||||
document.getElementById('timeEntriesTotalHours').textContent = total > 0 ? total.toFixed(2) : '—';
|
||||
}
|
||||
|
||||
// ── Modal helpers ─────────────────────────────────────────────────
|
||||
// -- Modal helpers -------------------------------------------------
|
||||
function openAdd() {
|
||||
document.getElementById('timeEntryModalTitle').textContent = 'Log Time';
|
||||
document.getElementById('teEntryId').value = '0';
|
||||
@@ -3028,7 +2917,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
// ── Deposits ─────────────────────────────────────────────────────────────
|
||||
// -- Deposits -------------------------------------------------------------
|
||||
// Note: antiForgeryToken() is already defined above in this script block
|
||||
document.getElementById('addDepositForm')?.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
@@ -3042,7 +2931,7 @@
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
@@ -3084,7 +2973,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Collapsible sections ──────────────────────────────────────────────────
|
||||
// -- Collapsible sections --------------------------------------------------
|
||||
(function () {
|
||||
const storageKey = 'jobDetailCollapse_@Model.Id';
|
||||
const sections = ['collapseTimeTracking', 'collapsePartIntake', 'collapsePhotos', 'collapseDeposits', 'collapseMaterials'];
|
||||
@@ -3123,7 +3012,7 @@
|
||||
});
|
||||
})();
|
||||
|
||||
// ── Part Intake Modal ─────────────────────────────────────────────────────
|
||||
// -- Part Intake Modal --------------------------------------------------
|
||||
(function () {
|
||||
const expectedCount = @intakeExpectedCount;
|
||||
const partCountInput = document.getElementById('intakePartCount');
|
||||
@@ -3216,7 +3105,7 @@
|
||||
<div class="mb-3">
|
||||
<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"
|
||||
placeholder="e.g. Wheel Refinish � Standard 4pc">
|
||||
placeholder="e.g. Wheel Refinish — Standard 4pc">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model PowderCoating.Application.DTOs.Job.UpdateJobDto
|
||||
@model PowderCoating.Application.DTOs.Job.UpdateJobDto
|
||||
@using PowderCoating.Core.Entities
|
||||
|
||||
@{
|
||||
@@ -45,7 +45,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
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>
|
||||
</a>
|
||||
</label>
|
||||
@@ -298,96 +298,8 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Surface Area Calculator Modal -->
|
||||
<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 (@(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>
|
||||
@await Html.PartialAsync("_SqFtCalculatorModal")
|
||||
@await Html.PartialAsync("_ItemWizardModal")
|
||||
|
||||
<!-- Embedded data for JS -->
|
||||
@if (ViewBag.InventoryCoatings != null)
|
||||
@@ -428,6 +340,7 @@
|
||||
complexity = item.Complexity,
|
||||
isGenericItem = item.IsGenericItem,
|
||||
isLaborItem = item.IsLaborItem,
|
||||
isAiItem = item.IsAiItem,
|
||||
requiresSandblasting = item.RequiresSandblasting,
|
||||
requiresMasking = item.RequiresMasking,
|
||||
notes = item.Notes,
|
||||
@@ -475,41 +388,7 @@
|
||||
|
||||
@section Styles {
|
||||
<link rel="stylesheet" href="~/lib/tom-select/css/tom-select.bootstrap5.min.css">
|
||||
<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; }
|
||||
[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>
|
||||
<link rel="stylesheet" href="~/css/item-wizard.css">
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model PowderCoating.Application.DTOs.Job.JobEditItemsViewModel
|
||||
@model PowderCoating.Application.DTOs.Job.JobEditItemsViewModel
|
||||
@using PowderCoating.Core.Entities
|
||||
|
||||
@{
|
||||
@@ -19,6 +19,9 @@
|
||||
<input type="hidden" name="JobNumber" value="@Model.JobNumber" />
|
||||
<input type="hidden" name="CustomerId" value="@Model.CustomerId" />
|
||||
<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)
|
||||
{
|
||||
@@ -94,98 +97,8 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Surface Area Calculator Modal -->
|
||||
<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 (@(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>
|
||||
@await Html.PartialAsync("_SqFtCalculatorModal")
|
||||
@await Html.PartialAsync("_ItemWizardModal")
|
||||
|
||||
<!-- Embedded data for JS -->
|
||||
@if (ViewBag.InventoryCoatings != null)
|
||||
@@ -223,6 +136,7 @@
|
||||
complexity = item.Complexity,
|
||||
isGenericItem = item.IsGenericItem,
|
||||
isLaborItem = item.IsLaborItem,
|
||||
isAiItem = item.IsAiItem,
|
||||
requiresSandblasting = item.RequiresSandblasting,
|
||||
requiresMasking = item.RequiresMasking,
|
||||
notes = item.Notes,
|
||||
@@ -256,7 +170,7 @@
|
||||
"discountType": "None",
|
||||
"discountValue": 0,
|
||||
"isRushJob": false,
|
||||
"ovenCostId": null,
|
||||
"ovenCostId": @Json.Serialize(Model.OvenCostId),
|
||||
"areaUnit": @Json.Serialize((string?)ViewBag.AreaUnit),
|
||||
"useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)),
|
||||
"pricingUrl": "@Url.Action("CalculatePricing", "Jobs")",
|
||||
@@ -266,42 +180,7 @@
|
||||
</script>
|
||||
|
||||
@section Styles {
|
||||
<style>
|
||||
/* 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>
|
||||
<link rel="stylesheet" href="~/css/item-wizard.css">
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
|
||||
@@ -71,6 +71,59 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var job in overdueJobs)
|
||||
{
|
||||
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", "Jobs", new { id = job.JobId })'">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #dc2626 0%, #991b1b 100%);">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@job.JobNumber</h6>
|
||||
<small>@job.CustomerName</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value"><span class="badge bg-@job.StatusColorClass">@job.StatusDisplayName</span></span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Priority</span>
|
||||
<span class="mobile-card-value"><span class="badge bg-@job.PriorityColorClass">@job.PriorityDisplayName</span></span>
|
||||
</div>
|
||||
@if (job.ScheduledDate.HasValue)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Scheduled</span>
|
||||
<span class="mobile-card-value text-danger fw-bold">@job.ScheduledDate.Value.ToString("MMM d, yyyy")</span>
|
||||
</div>
|
||||
}
|
||||
@if (job.DueDate.HasValue)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Due</span>
|
||||
<span class="mobile-card-value text-danger">@job.DueDate.Value.ToString("MMM d, yyyy")</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@job.JobId" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
|
||||
<i class="bi bi-eye me-1"></i>View
|
||||
</a>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation(); openPriorityModal(@job.JobId, @job.JobPriorityId, '@job.JobNumber')">
|
||||
<i class="bi bi-flag"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation(); openWorkerModal(@job.JobId, '@(job.AssignedUserId ?? "")', '@job.JobNumber')">
|
||||
<i class="bi bi-person"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
@@ -191,6 +244,74 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var job in Model)
|
||||
{
|
||||
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", "Jobs", new { id = job.JobId })'">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);">
|
||||
<i class="bi bi-kanban"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@job.JobNumber</h6>
|
||||
<small>@job.CustomerName</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value"><span class="badge bg-@job.StatusColorClass" id="status-badge-@job.JobId">@job.StatusDisplayName</span></span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Priority</span>
|
||||
<span class="mobile-card-value"><span class="badge bg-@job.PriorityColorClass priority-badge-@job.JobId">@job.PriorityDisplayName</span></span>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(job.AssignedWorkerName))
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Worker</span>
|
||||
<span class="mobile-card-value"><span class="badge bg-info"><i class="bi bi-person me-1"></i>@job.AssignedWorkerName</span></span>
|
||||
</div>
|
||||
}
|
||||
@if (job.ScheduledDate.HasValue)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Scheduled</span>
|
||||
<span class="mobile-card-value">@job.ScheduledDate.Value.ToString("MMM d, yyyy")</span>
|
||||
</div>
|
||||
}
|
||||
@if (job.DueDate.HasValue)
|
||||
{
|
||||
var mJobOverdue = job.DueDate.Value.Date < DateTime.Today;
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Due</span>
|
||||
<span class="mobile-card-value @(mJobOverdue ? "text-danger fw-bold" : "")">@job.DueDate.Value.ToString("MMM d, yyyy")</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@job.JobId" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
|
||||
<i class="bi bi-eye me-1"></i>View
|
||||
</a>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation(); openPriorityModal(@job.JobId, @job.JobPriorityId, '@job.JobNumber')" title="Change Priority">
|
||||
<i class="bi bi-flag"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation(); openWorkerModal(@job.JobId, '@(job.AssignedUserId ?? "")', '@job.JobNumber')" title="Assign Worker">
|
||||
<i class="bi bi-person"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (!Model.Any())
|
||||
{
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-calendar-check fs-1 d-block mb-2 opacity-25"></i>
|
||||
No jobs scheduled for @scheduledDate.ToString("MMMM dd, yyyy").
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0" id="jobsTable">
|
||||
<thead>
|
||||
@@ -352,6 +473,65 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var item in maintenanceItems)
|
||||
{
|
||||
var mPriorityBg = item.Priority switch
|
||||
{
|
||||
MaintenancePriority.Critical => "danger",
|
||||
MaintenancePriority.High => "warning",
|
||||
MaintenancePriority.Normal => "info",
|
||||
_ => "secondary"
|
||||
};
|
||||
var mStatusBgM = item.Status == MaintenanceStatus.InProgress ? "success" : "primary";
|
||||
var mStatusLbl = item.Status == MaintenanceStatus.InProgress ? "In Progress" : "Scheduled";
|
||||
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", "Maintenance", new { id = item.Id })'">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #f59e0b 0%, #b45309 100%);">
|
||||
<i class="bi bi-tools"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@(item.Equipment?.EquipmentName ?? "Maintenance")</h6>
|
||||
<small>@item.MaintenanceType</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Priority</span>
|
||||
<span class="mobile-card-value"><span class="badge bg-@mPriorityBg">@item.Priority</span></span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value"><span class="badge bg-@mStatusBgM">@mStatusLbl</span></span>
|
||||
</div>
|
||||
@if (item.AssignedUser != null)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Worker</span>
|
||||
<span class="mobile-card-value"><span class="badge bg-info text-dark"><i class="bi bi-person me-1"></i>@item.AssignedUser.FullName</span></span>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(item.Description))
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Desc.</span>
|
||||
<span class="mobile-card-value text-muted">@item.Description</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-controller="Maintenance" asp-action="Details" asp-route-id="@item.Id" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
|
||||
<i class="bi bi-eye me-1"></i>View
|
||||
</a>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation(); openMaintenanceWorkerModal(@item.Id, '@(item.AssignedUserId ?? "")', '@(item.Equipment?.EquipmentName ?? "Maintenance")')" title="Assign Worker">
|
||||
<i class="bi bi-person"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
|
||||
@@ -47,6 +47,68 @@
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var je in Model)
|
||||
{
|
||||
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", new { id = je.Id })'">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);">
|
||||
<i class="bi bi-journal-text"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>
|
||||
@je.EntryNumber
|
||||
@if (je.IsReversal)
|
||||
{
|
||||
<span class="badge bg-secondary ms-1">REV</span>
|
||||
}
|
||||
</h6>
|
||||
<small>@je.EntryDate.ToString("MMM d, yyyy")</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value">
|
||||
@if (je.Status == JournalEntryStatus.Draft)
|
||||
{
|
||||
<span class="badge bg-warning text-dark">Draft</span>
|
||||
}
|
||||
else if (je.Status == JournalEntryStatus.Posted)
|
||||
{
|
||||
<span class="badge bg-success">Posted</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary">Reversed</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(je.Description))
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Description</span>
|
||||
<span class="mobile-card-value" style="white-space:normal;text-align:right;">@je.Description</span>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(je.Reference))
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Reference</span>
|
||||
<span class="mobile-card-value">@je.Reference</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-action="Details" asp-route-id="@je.Id" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
|
||||
View
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
@{
|
||||
Layout = "~/Views/Shared/_KioskLayout.cshtml";
|
||||
ViewData["Title"] = "Terms & Consent";
|
||||
var token = ViewBag.SessionToken as Guid? ?? Guid.Empty;
|
||||
bool isInPerson = ViewBag.IsInPerson as bool? ?? false;
|
||||
var token = ViewBag.SessionToken as Guid? ?? Guid.Empty;
|
||||
bool isInPerson = ViewBag.IsInPerson as bool? ?? false;
|
||||
bool quoteFirst = !string.Equals(ViewBag.KioskIntakeOutput as string, "Job", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
<div class="kiosk-card">
|
||||
@@ -25,10 +26,20 @@
|
||||
have authority to authorize work on them. You release the shop from liability for
|
||||
pre-existing damage, hidden defects, or items left unclaimed after 30 days.
|
||||
</p>
|
||||
<p>
|
||||
Final pricing is subject to a formal quote. Work will not begin until you approve
|
||||
the quoted amount. Payment is due upon pickup unless otherwise agreed in writing.
|
||||
</p>
|
||||
@if (quoteFirst)
|
||||
{
|
||||
<p>
|
||||
Final pricing is subject to a formal quote. Work will not begin until you approve
|
||||
the quoted amount. Payment is due upon pickup unless otherwise agreed in writing.
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p>
|
||||
A team member will review your intake and reach out about pricing before work begins.
|
||||
Payment is due upon pickup unless otherwise agreed in writing.
|
||||
</p>
|
||||
}
|
||||
<p class="mb-0">
|
||||
You agree to comply with all pickup and payment terms provided by the shop.
|
||||
</p>
|
||||
@@ -90,9 +101,7 @@
|
||||
@if (isInPerson)
|
||||
{
|
||||
@section Scripts {
|
||||
<script src="https://cdn.jsdelivr.net/npm/signature_pad@4.1.7/dist/signature_pad.umd.min.js"
|
||||
integrity="sha384-bQMMRVcRi5vEIBLKnB4FY7tBOA9k/Qvd/9zSWMNO4h0zfB2qLj4DV2R/JyPAbF3"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="~/lib/signature-pad/signature_pad.umd.min.js"></script>
|
||||
<script src="~/js/kiosk-terms.js"></script>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
string activeFilter = ViewBag.ActiveFilter as string ?? "all";
|
||||
}
|
||||
|
||||
<div class="container-fluid px-4">
|
||||
<div>
|
||||
<div class="d-flex align-items-center justify-content-between mb-4 flex-wrap gap-2">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<i class="bi bi-clipboard-check fs-3 text-primary"></i>
|
||||
@@ -53,17 +53,116 @@
|
||||
else
|
||||
{
|
||||
<div class="card">
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var s in Model)
|
||||
{
|
||||
<div class="mobile-data-card">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);">
|
||||
<i class="bi bi-clipboard-check"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@s.CustomerFullName</h6>
|
||||
<small>@(s.SubmittedAt?.ToLocalTime().ToString("MM/dd/yy h:mm tt") ?? s.ExpiresAt.AddHours(-2).ToLocalTime().ToString("MM/dd/yy h:mm tt"))</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value">
|
||||
@if (s.Status == KioskSessionStatus.Submitted && s.IsConverted)
|
||||
{
|
||||
<span class="badge bg-success">Converted</span>
|
||||
}
|
||||
else if (s.Status == KioskSessionStatus.Submitted)
|
||||
{
|
||||
<span class="badge bg-info text-dark">Submitted</span>
|
||||
}
|
||||
else if (s.Status == KioskSessionStatus.Active && !s.IsExpired)
|
||||
{
|
||||
<span class="badge bg-warning text-dark">In Progress</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary">Expired</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Type</span>
|
||||
<span class="mobile-card-value">
|
||||
@if (s.SessionType == KioskSessionType.InPerson)
|
||||
{
|
||||
<span class="badge bg-primary-subtle text-primary"><i class="bi bi-tablet me-1"></i>In-Person</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge" style="background:#ede9fe;color:#6d28d9;"><i class="bi bi-envelope me-1"></i>Remote</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(s.CustomerPhone))
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Phone</span>
|
||||
<span class="mobile-card-value"><a href="tel:@s.CustomerPhone">@s.CustomerPhone</a></span>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(s.CustomerEmail))
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Email</span>
|
||||
<span class="mobile-card-value" style="white-space:normal;"><a href="mailto:@s.CustomerEmail">@s.CustomerEmail</a></span>
|
||||
</div>
|
||||
}
|
||||
@if (s.LinkedCustomerId.HasValue)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Matched</span>
|
||||
<span class="mobile-card-value">
|
||||
<a href="/Customers/Details/@s.LinkedCustomerId" class="text-success">
|
||||
<i class="bi bi-person-check me-1"></i>Customer record
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
@if (s.LinkedJobId.HasValue)
|
||||
{
|
||||
<a href="/Jobs/Details/@s.LinkedJobId" class="btn btn-sm btn-outline-success">
|
||||
<i class="bi bi-briefcase me-1"></i>Job
|
||||
</a>
|
||||
}
|
||||
@if (s.LinkedQuoteId.HasValue)
|
||||
{
|
||||
<a href="/Quotes/Details/@s.LinkedQuoteId" class="btn btn-sm btn-outline-info">
|
||||
<i class="bi bi-file-earmark-text me-1"></i>Quote
|
||||
</a>
|
||||
}
|
||||
@if (s.LinkedCustomerId.HasValue)
|
||||
{
|
||||
<a href="/Customers/Details/@s.LinkedCustomerId" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-person me-1"></i>Customer
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th class="d-none d-md-table-cell">Date</th>
|
||||
<th>Customer</th>
|
||||
<th>Contact</th>
|
||||
<th>Project</th>
|
||||
<th>Type</th>
|
||||
<th class="d-none d-lg-table-cell">Contact</th>
|
||||
<th class="d-none d-lg-table-cell">Project</th>
|
||||
<th class="d-none d-sm-table-cell">Type</th>
|
||||
<th>Status</th>
|
||||
<th>SMS</th>
|
||||
<th class="d-none d-md-table-cell">SMS</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -71,7 +170,7 @@
|
||||
@foreach (var s in Model)
|
||||
{
|
||||
<tr>
|
||||
<td class="text-nowrap text-muted small">
|
||||
<td class="text-nowrap text-muted small d-none d-md-table-cell">
|
||||
@(s.SubmittedAt?.ToLocalTime().ToString("MM/dd/yy h:mm tt") ?? s.ExpiresAt.AddHours(-2).ToLocalTime().ToString("MM/dd/yy h:mm tt"))
|
||||
</td>
|
||||
<td>
|
||||
@@ -82,8 +181,12 @@
|
||||
<i class="bi bi-person-check me-1"></i>Customer matched
|
||||
</a>
|
||||
}
|
||||
@* Show date inline on mobile since the Date column is hidden *@
|
||||
<div class="text-muted small d-md-none">
|
||||
@(s.SubmittedAt?.ToLocalTime().ToString("MM/dd/yy h:mm tt") ?? s.ExpiresAt.AddHours(-2).ToLocalTime().ToString("MM/dd/yy h:mm tt"))
|
||||
</div>
|
||||
</td>
|
||||
<td class="small text-muted">
|
||||
<td class="small text-muted d-none d-lg-table-cell">
|
||||
@if (!string.IsNullOrEmpty(s.CustomerPhone))
|
||||
{
|
||||
<div><i class="bi bi-telephone me-1"></i>@s.CustomerPhone</div>
|
||||
@@ -93,11 +196,11 @@
|
||||
<div><i class="bi bi-envelope me-1"></i>@s.CustomerEmail</div>
|
||||
}
|
||||
</td>
|
||||
<td style="max-width:280px;">
|
||||
<td class="d-none d-lg-table-cell" style="max-width:280px;">
|
||||
<span class="text-truncate d-block" style="max-width:260px;"
|
||||
title="@s.JobDescription">@s.JobDescriptionSnippet</span>
|
||||
</td>
|
||||
<td>
|
||||
<td class="d-none d-sm-table-cell">
|
||||
@if (s.SessionType == KioskSessionType.InPerson)
|
||||
{
|
||||
<span class="badge bg-primary-subtle text-primary">
|
||||
@@ -129,7 +232,7 @@
|
||||
<span class="badge bg-secondary">Expired</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<td class="d-none d-md-table-cell">
|
||||
@if (s.SmsOptIn)
|
||||
{
|
||||
<i class="bi bi-check-circle-fill text-success" title="SMS opt-in"></i>
|
||||
@@ -143,13 +246,19 @@
|
||||
@if (s.LinkedJobId.HasValue)
|
||||
{
|
||||
<a href="/Jobs/Details/@s.LinkedJobId" class="btn btn-sm btn-outline-success me-1">
|
||||
<i class="bi bi-briefcase me-1"></i>View Job
|
||||
<i class="bi bi-briefcase me-1"></i><span class="d-none d-sm-inline">View Job</span><span class="d-sm-none">Job</span>
|
||||
</a>
|
||||
}
|
||||
@if (s.LinkedQuoteId.HasValue)
|
||||
{
|
||||
<a href="/Quotes/Details/@s.LinkedQuoteId" class="btn btn-sm btn-outline-info me-1">
|
||||
<i class="bi bi-file-earmark-text me-1"></i><span class="d-none d-sm-inline">View Quote</span><span class="d-sm-none">Quote</span>
|
||||
</a>
|
||||
}
|
||||
@if (s.LinkedCustomerId.HasValue)
|
||||
{
|
||||
<a href="/Customers/Details/@s.LinkedCustomerId" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-person me-1"></i>Customer
|
||||
<i class="bi bi-person me-1"></i><span class="d-none d-sm-inline">Customer</span>
|
||||
</a>
|
||||
}
|
||||
</td>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
@model int
|
||||
@{
|
||||
Layout = "~/Views/Shared/_KioskLayout.cshtml";
|
||||
ViewData["Title"] = "SMS Consent";
|
||||
string customerName = ViewBag.CustomerName as string ?? "Customer";
|
||||
}
|
||||
|
||||
<div class="kiosk-card">
|
||||
<h2 class="fw-bold mb-1" style="font-size:1.6rem;">SMS Notifications</h2>
|
||||
<p class="text-muted mb-4">Please read the following and tap <strong>I Agree</strong> to opt in.</p>
|
||||
|
||||
<form method="post" action="/Kiosk/SmsConsent/@Model">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="agreed" value="true" />
|
||||
|
||||
<div class="kiosk-terms-scroll mb-4">
|
||||
<strong>SMS Consent & Opt-In</strong>
|
||||
<p class="mt-2">
|
||||
By tapping <em>I Agree</em> below, <strong>@customerName</strong> consents to receive
|
||||
SMS text messages from @(ViewBag.CompanyName ?? "this shop") regarding order status
|
||||
updates, pickup notifications, and other information related to your powder coating
|
||||
services.
|
||||
</p>
|
||||
<p>
|
||||
Message frequency varies. Message and data rates may apply.
|
||||
You may opt out at any time by replying <strong>STOP</strong> to any message.
|
||||
Reply <strong>HELP</strong> for assistance.
|
||||
</p>
|
||||
<p class="mb-0">
|
||||
Your mobile number will not be shared with third parties or used for marketing
|
||||
unrelated to your orders.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-3">
|
||||
<a href="/Kiosk/SmsConsent/@Model?agreed=false"
|
||||
onclick="event.preventDefault(); document.getElementById('declineForm').submit();"
|
||||
class="btn btn-outline-secondary"
|
||||
style="min-height:64px;border-radius:12px;font-size:1.1rem;flex:0 0 auto;padding:0 2rem;">
|
||||
<i class="bi bi-x-lg me-1"></i> No Thanks
|
||||
</a>
|
||||
<button type="submit" class="btn btn-success kiosk-btn">
|
||||
<i class="bi bi-check-circle me-2"></i> I Agree
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@* Separate form for decline so "No Thanks" can POST with agreed=false *@
|
||||
<form id="declineForm" method="post" action="/Kiosk/SmsConsent/@Model" style="display:none;">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="agreed" value="false" />
|
||||
</form>
|
||||
</div>
|
||||
@@ -23,8 +23,8 @@
|
||||
|
||||
<div class="kiosk-idle-indicator">
|
||||
<span id="kiosk-conn-dot" style="display:inline-block;width:10px;height:10px;
|
||||
border-radius:50%;background:#16a34a;margin-right:6px;transition:background 0.3s;"></span>
|
||||
Ready
|
||||
border-radius:50%;background:#94a3b8;margin-right:6px;transition:background 0.3s;"></span>
|
||||
<span id="kiosk-conn-label">Connecting…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -134,6 +134,67 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var item in Model.Items)
|
||||
{
|
||||
<div class="mobile-data-card">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: @(item.Channel == PowderCoating.Core.Enums.NotificationChannel.Email ? "linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)" : "linear-gradient(135deg, #06b6d4 0%, #0e7490 100%)");">
|
||||
<i class="bi @(item.Channel == PowderCoating.Core.Enums.NotificationChannel.Email ? "bi-envelope" : "bi-phone")"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@item.RecipientName</h6>
|
||||
<small>@item.Recipient</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Type</span>
|
||||
<span class="mobile-card-value">@item.NotificationTypeDisplay</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Sent</span>
|
||||
<span class="mobile-card-value">@item.SentAt.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd HH:mm")</span>
|
||||
</div>
|
||||
@if (item.JobId.HasValue)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Job</span>
|
||||
<span class="mobile-card-value">@item.JobNumber</span>
|
||||
</div>
|
||||
}
|
||||
else if (item.QuoteId.HasValue)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Quote</span>
|
||||
<span class="mobile-card-value">@item.QuoteNumber</span>
|
||||
</div>
|
||||
}
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value">
|
||||
@{
|
||||
var (mStatusBadge, mStatusIcon) = item.Status switch
|
||||
{
|
||||
PowderCoating.Core.Enums.NotificationStatus.Sent => ("bg-success", "bi-check-circle"),
|
||||
PowderCoating.Core.Enums.NotificationStatus.Failed => ("bg-danger", "bi-x-circle"),
|
||||
_ => ("bg-secondary", "bi-dash-circle")
|
||||
};
|
||||
}
|
||||
<span class="badge @mStatusBadge"><i class="bi @mStatusIcon me-1"></i>@item.StatusDisplay</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-action="Details" asp-route-id="@item.Id" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-eye me-1"></i>View
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
|
||||
@@ -44,6 +44,91 @@
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var row in Model.Rows)
|
||||
{
|
||||
var oPct = row.TotalSteps == 0 ? 0 : row.StepsCompleted * 100 / row.TotalSteps;
|
||||
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", "Companies", new { id = row.CompanyId })'">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);">
|
||||
<i class="bi bi-building"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@row.CompanyName</h6>
|
||||
<small>
|
||||
@switch (row.Status)
|
||||
{
|
||||
case OnboardingStatus.Complete:
|
||||
<span class="badge bg-success">Complete</span>
|
||||
break;
|
||||
case OnboardingStatus.InProgress:
|
||||
<span class="badge bg-warning text-dark">In Progress</span>
|
||||
break;
|
||||
case OnboardingStatus.Dismissed:
|
||||
<span class="badge bg-secondary">Dismissed</span>
|
||||
break;
|
||||
default:
|
||||
<span class="badge bg-light text-muted border">Not Started</span>
|
||||
break;
|
||||
}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Wizard</span>
|
||||
<span class="mobile-card-value">
|
||||
@if (row.WizardCompleted)
|
||||
{
|
||||
<i class="bi bi-check-circle-fill text-success"></i>
|
||||
<span class="text-success ms-1">Done</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-circle text-muted"></i>
|
||||
<span class="text-muted ms-1">Pending</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Milestones</span>
|
||||
<span class="mobile-card-value">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="progress" style="height:5px; width:60px;">
|
||||
<div class="progress-bar @(oPct == 100 ? "bg-success" : "bg-primary")" style="width:@oPct%"></div>
|
||||
</div>
|
||||
<small class="text-muted">@row.StepsCompleted/@row.TotalSteps</small>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
@{
|
||||
var oFirstActivity = row.FirstJobCreatedAt ?? row.FirstQuoteCreatedAt;
|
||||
}
|
||||
@if (oFirstActivity.HasValue)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">First Activity</span>
|
||||
<span class="mobile-card-value text-muted">@oFirstActivity.Value.ToString("MMM d, yyyy")</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-controller="Companies" asp-action="Details" asp-route-id="@row.CompanyId" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
|
||||
<i class="bi bi-building me-1"></i>View
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (!Model.Rows.Any())
|
||||
{
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-building fs-1 d-block mb-2 opacity-25"></i>
|
||||
No companies found.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0" id="onboardingTable">
|
||||
<thead class="table-light">
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Personal Information"
|
||||
data-bs-content="First Name, Last Name, and Phone are editable and saved when you click Save Profile. Email is shown here for reference — change it on the Security tab. Department, Position, Role, and Employee Number are set by an administrator and cannot be changed here.">
|
||||
data-bs-content="First Name, Last Name, and Phone are editable and saved when you click Save Profile. Email is shown here for reference â€" change it on the Security tab. Department, Position, Role, and Employee Number are set by an administrator and cannot be changed here.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -290,7 +290,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Appearance Settings"
|
||||
data-bs-content="Theme switches the app between Light and Dark mode. Sidebar Color changes the navigation panel background — click a swatch to preview it instantly. Date Format controls how dates display throughout the app. Timezone is used to localize timestamps. Click Save Appearance to persist your choices.">
|
||||
data-bs-content="Theme switches the app between Light and Dark mode. Sidebar Color changes the navigation panel background â€" click a swatch to preview it instantly. Date Format controls how dates display throughout the app. Timezone is used to localize timestamps. Click Save Appearance to persist your choices.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -364,39 +364,39 @@
|
||||
<label class="form-label fw-semibold">Timezone</label>
|
||||
<select class="form-select" id="timezoneInput" name="TimeZone" style="max-width:350px;">
|
||||
<optgroup label="United States">
|
||||
<option value="America/New_York" selected="@(Model.TimeZone == "America/New_York" ? "selected" : null)">Eastern (ET) — New York</option>
|
||||
<option value="America/Chicago" selected="@(Model.TimeZone == "America/Chicago" ? "selected" : null)">Central (CT) — Chicago</option>
|
||||
<option value="America/Denver" selected="@(Model.TimeZone == "America/Denver" ? "selected" : null)">Mountain (MT) — Denver</option>
|
||||
<option value="America/Phoenix" selected="@(Model.TimeZone == "America/Phoenix" ? "selected" : null)">Mountain no-DST — Phoenix</option>
|
||||
<option value="America/Los_Angeles" selected="@(Model.TimeZone == "America/Los_Angeles" ? "selected" : null)">Pacific (PT) — Los Angeles</option>
|
||||
<option value="America/Anchorage" selected="@(Model.TimeZone == "America/Anchorage" ? "selected" : null)">Alaska (AKT) — Anchorage</option>
|
||||
<option value="Pacific/Honolulu" selected="@(Model.TimeZone == "Pacific/Honolulu" ? "selected" : null)">Hawaii (HT) — Honolulu</option>
|
||||
<option value="America/New_York" selected="@(Model.TimeZone == "America/New_York" ? "selected" : null)">Eastern (ET) — New York</option>
|
||||
<option value="America/Chicago" selected="@(Model.TimeZone == "America/Chicago" ? "selected" : null)">Central (CT) — Chicago</option>
|
||||
<option value="America/Denver" selected="@(Model.TimeZone == "America/Denver" ? "selected" : null)">Mountain (MT) — Denver</option>
|
||||
<option value="America/Phoenix" selected="@(Model.TimeZone == "America/Phoenix" ? "selected" : null)">Mountain no-DST — Phoenix</option>
|
||||
<option value="America/Los_Angeles" selected="@(Model.TimeZone == "America/Los_Angeles" ? "selected" : null)">Pacific (PT) — Los Angeles</option>
|
||||
<option value="America/Anchorage" selected="@(Model.TimeZone == "America/Anchorage" ? "selected" : null)">Alaska (AKT) — Anchorage</option>
|
||||
<option value="Pacific/Honolulu" selected="@(Model.TimeZone == "Pacific/Honolulu" ? "selected" : null)">Hawaii (HT) — Honolulu</option>
|
||||
</optgroup>
|
||||
<optgroup label="Canada">
|
||||
<option value="America/Halifax" selected="@(Model.TimeZone == "America/Halifax" ? "selected" : null)">Atlantic (AT) — Halifax</option>
|
||||
<option value="America/Toronto" selected="@(Model.TimeZone == "America/Toronto" ? "selected" : null)">Eastern — Toronto</option>
|
||||
<option value="America/Winnipeg" selected="@(Model.TimeZone == "America/Winnipeg" ? "selected" : null)">Central — Winnipeg</option>
|
||||
<option value="America/Edmonton" selected="@(Model.TimeZone == "America/Edmonton" ? "selected" : null)">Mountain — Edmonton</option>
|
||||
<option value="America/Vancouver" selected="@(Model.TimeZone == "America/Vancouver" ? "selected" : null)">Pacific — Vancouver</option>
|
||||
<option value="America/Halifax" selected="@(Model.TimeZone == "America/Halifax" ? "selected" : null)">Atlantic (AT) — Halifax</option>
|
||||
<option value="America/Toronto" selected="@(Model.TimeZone == "America/Toronto" ? "selected" : null)">Eastern — Toronto</option>
|
||||
<option value="America/Winnipeg" selected="@(Model.TimeZone == "America/Winnipeg" ? "selected" : null)">Central — Winnipeg</option>
|
||||
<option value="America/Edmonton" selected="@(Model.TimeZone == "America/Edmonton" ? "selected" : null)">Mountain — Edmonton</option>
|
||||
<option value="America/Vancouver" selected="@(Model.TimeZone == "America/Vancouver" ? "selected" : null)">Pacific — Vancouver</option>
|
||||
</optgroup>
|
||||
<optgroup label="Europe">
|
||||
<option value="Europe/London" selected="@(Model.TimeZone == "Europe/London" ? "selected" : null)">GMT/BST — London</option>
|
||||
<option value="Europe/Paris" selected="@(Model.TimeZone == "Europe/Paris" ? "selected" : null)">CET — Paris / Berlin</option>
|
||||
<option value="Europe/Helsinki" selected="@(Model.TimeZone == "Europe/Helsinki" ? "selected" : null)">EET — Helsinki</option>
|
||||
<option value="Europe/Moscow" selected="@(Model.TimeZone == "Europe/Moscow" ? "selected" : null)">MSK — Moscow</option>
|
||||
<option value="Europe/London" selected="@(Model.TimeZone == "Europe/London" ? "selected" : null)">GMT/BST — London</option>
|
||||
<option value="Europe/Paris" selected="@(Model.TimeZone == "Europe/Paris" ? "selected" : null)">CET — Paris / Berlin</option>
|
||||
<option value="Europe/Helsinki" selected="@(Model.TimeZone == "Europe/Helsinki" ? "selected" : null)">EET — Helsinki</option>
|
||||
<option value="Europe/Moscow" selected="@(Model.TimeZone == "Europe/Moscow" ? "selected" : null)">MSK — Moscow</option>
|
||||
</optgroup>
|
||||
<optgroup label="Asia / Pacific">
|
||||
<option value="Asia/Dubai" selected="@(Model.TimeZone == "Asia/Dubai" ? "selected" : null)">GST — Dubai</option>
|
||||
<option value="Asia/Kolkata" selected="@(Model.TimeZone == "Asia/Kolkata" ? "selected" : null)">IST — India</option>
|
||||
<option value="Asia/Bangkok" selected="@(Model.TimeZone == "Asia/Bangkok" ? "selected" : null)">ICT — Bangkok</option>
|
||||
<option value="Asia/Shanghai" selected="@(Model.TimeZone == "Asia/Shanghai" ? "selected" : null)">CST — Beijing / Shanghai</option>
|
||||
<option value="Asia/Tokyo" selected="@(Model.TimeZone == "Asia/Tokyo" ? "selected" : null)">JST — Tokyo</option>
|
||||
<option value="Australia/Sydney" selected="@(Model.TimeZone == "Australia/Sydney" ? "selected" : null)">AEST — Sydney</option>
|
||||
<option value="Pacific/Auckland" selected="@(Model.TimeZone == "Pacific/Auckland" ? "selected" : null)">NZST — Auckland</option>
|
||||
<option value="Asia/Dubai" selected="@(Model.TimeZone == "Asia/Dubai" ? "selected" : null)">GST — Dubai</option>
|
||||
<option value="Asia/Kolkata" selected="@(Model.TimeZone == "Asia/Kolkata" ? "selected" : null)">IST — India</option>
|
||||
<option value="Asia/Bangkok" selected="@(Model.TimeZone == "Asia/Bangkok" ? "selected" : null)">ICT — Bangkok</option>
|
||||
<option value="Asia/Shanghai" selected="@(Model.TimeZone == "Asia/Shanghai" ? "selected" : null)">CST — Beijing / Shanghai</option>
|
||||
<option value="Asia/Tokyo" selected="@(Model.TimeZone == "Asia/Tokyo" ? "selected" : null)">JST — Tokyo</option>
|
||||
<option value="Australia/Sydney" selected="@(Model.TimeZone == "Australia/Sydney" ? "selected" : null)">AEST — Sydney</option>
|
||||
<option value="Pacific/Auckland" selected="@(Model.TimeZone == "Pacific/Auckland" ? "selected" : null)">NZST — Auckland</option>
|
||||
</optgroup>
|
||||
<optgroup label="South America">
|
||||
<option value="America/Sao_Paulo" selected="@(Model.TimeZone == "America/Sao_Paulo" ? "selected" : null)">BRT — São Paulo</option>
|
||||
<option value="America/Buenos_Aires" selected="@(Model.TimeZone == "America/Buenos_Aires" ? "selected" : null)">ART — Buenos Aires</option>
|
||||
<option value="America/Sao_Paulo" selected="@(Model.TimeZone == "America/Sao_Paulo" ? "selected" : null)">BRT — São Paulo</option>
|
||||
<option value="America/Buenos_Aires" selected="@(Model.TimeZone == "America/Buenos_Aires" ? "selected" : null)">ART — Buenos Aires</option>
|
||||
</optgroup>
|
||||
<optgroup label="UTC">
|
||||
<option value="UTC" selected="@(Model.TimeZone == "UTC" ? "selected" : null)">UTC</option>
|
||||
@@ -538,7 +538,7 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Theme radio — map light/dark → paper/ink surface system
|
||||
// Theme radio â€" map light/dark → paper/ink surface system
|
||||
document.querySelectorAll('input[name="theme"]').forEach(radio => {
|
||||
radio.addEventListener('change', function () {
|
||||
var surface = this.value === 'dark' ? 'ink' : 'paper';
|
||||
|
||||
@@ -164,6 +164,56 @@
|
||||
<!-- Grid -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var po in Model.Items)
|
||||
{
|
||||
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", new { id = po.Id })'">
|
||||
<div class="mobile-card-header" style="@(po.IsOverdue ? "background:#fee2e2;" : "")">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);">
|
||||
<i class="bi bi-cart-check"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@po.PoNumber @(po.IsOverdue ? " — Overdue" : "")</h6>
|
||||
<small>@po.VendorName</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value"><span class="badge bg-@StatusBadge(po.Status)">@po.Status</span></span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Order Date</span>
|
||||
<span class="mobile-card-value">@po.OrderDate.ToString("MM/dd/yy")</span>
|
||||
</div>
|
||||
@if (po.ExpectedDeliveryDate.HasValue)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Expected</span>
|
||||
<span class="mobile-card-value @(po.IsOverdue ? "text-danger fw-semibold" : "")">
|
||||
@po.ExpectedDeliveryDate.Value.ToString("MM/dd/yy")
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Items</span>
|
||||
<span class="mobile-card-value">@po.ItemCount</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Total</span>
|
||||
<span class="mobile-card-value fw-semibold">$@po.TotalAmount.ToString("N2")</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-action="Details" asp-route-id="@po.Id" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
|
||||
<i class="bi bi-eye me-1"></i>View
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model PowderCoating.Application.DTOs.Quote.CreateQuoteDto
|
||||
@model PowderCoating.Application.DTOs.Quote.CreateQuoteDto
|
||||
@using PowderCoating.Core.Entities
|
||||
|
||||
@{
|
||||
@@ -51,7 +51,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right"
|
||||
data-bs-title="Customer vs Prospect/Walk-In"
|
||||
data-bs-content="Choose <strong>Existing Customer</strong> if this person is already in your system. Choose <strong>New Prospect/Walk-In</strong> if they haven't committed yet — their details stay on the quote. When they approve, you can convert them to a full customer record with one click.<br><br><a href='/Help/Quotes#prospect-conversion' target='_blank'>Learn more →</a>">
|
||||
data-bs-content="Choose <strong>Existing Customer</strong> if this person is already in your system. Choose <strong>New Prospect/Walk-In</strong> if they haven't committed yet — their details stay on the quote. When they approve, you can convert them to a full customer record with one click.<br><br><a href='/Help/Quotes#prospect-conversion' target='_blank'>Learn more →</a>">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</h5>
|
||||
@@ -146,7 +146,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right"
|
||||
data-bs-title="Quote Information"
|
||||
data-bs-content="Set the quote date, expiration, and any internal notes. The <strong>Expiration Date</strong> is shown to the customer — once it passes the quote is flagged Expired and can no longer be approved without editing. The <strong>Customer PO</strong> field is optional — use it if the customer provides their own purchase order number.<br><br><a href='/Help/Quotes#quote-statuses' target='_blank'>Learn more →</a>">
|
||||
data-bs-content="Set the quote date, expiration, and any internal notes. The <strong>Expiration Date</strong> is shown to the customer — once it passes the quote is flagged Expired and can no longer be approved without editing. The <strong>Customer PO</strong> field is optional — use it if the customer provides their own purchase order number.<br><br><a href='/Help/Quotes#quote-statuses' target='_blank'>Learn more →</a>">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</h5>
|
||||
@@ -253,7 +253,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right"
|
||||
data-bs-title="Quote Item Types"
|
||||
data-bs-content="<strong>Calculated</strong> — you enter surface area (sq ft) and the system prices it using your rates for materials, labour, and overhead.<br><strong>Custom Work</strong> — you enter a description and a manual price. Use this for flat-rate jobs or work that doesn't fit the formula.<br><strong>AI Photo</strong> — upload photos and let the AI estimate surface area and complexity for you.<br><br><a href='/Help/Quotes#quote-items' target='_blank'>Learn more →</a>">
|
||||
data-bs-content="<strong>Calculated</strong> — you enter surface area (sq ft) and the system prices it using your rates for materials, labour, and overhead.<br><strong>Custom Work</strong> — you enter a description and a manual price. Use this for flat-rate jobs or work that doesn't fit the formula.<br><strong>AI Photo</strong> — upload photos and let the AI estimate surface area and complexity for you.<br><br><a href='/Help/Quotes#quote-items' target='_blank'>Learn more →</a>">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</h5>
|
||||
@@ -329,7 +329,7 @@
|
||||
<a tabindex="0" class="help-icon text-white" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="left"
|
||||
data-bs-title="Pricing Summary"
|
||||
data-bs-content="The total is built up from materials, labour, equipment time, overhead, and profit margin — all based on the rates in Settings. A <strong>Tier Discount</strong> appears automatically if the customer has a pricing tier assigned. A <strong>Rush Fee</strong> is added when Rush Job is checked.<br><br><a href='/Help/Quotes#pricing-breakdown' target='_blank'>Learn more →</a>">
|
||||
data-bs-content="The total is built up from materials, labour, equipment time, overhead, and profit margin — all based on the rates in Settings. A <strong>Tier Discount</strong> appears automatically if the customer has a pricing tier assigned. A <strong>Rush Fee</strong> is added when Rush Job is checked.<br><br><a href='/Help/Quotes#pricing-breakdown' target='_blank'>Learn more →</a>">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</h5>
|
||||
@@ -422,99 +422,8 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Surface Area Calculator Modal -->
|
||||
<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 (@(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>
|
||||
@await Html.PartialAsync("_SqFtCalculatorModal")
|
||||
@await Html.PartialAsync("_ItemWizardModal")
|
||||
|
||||
<!-- Embedded data for JS -->
|
||||
@if (ViewBag.InventoryCoatings != null)
|
||||
@@ -633,47 +542,8 @@
|
||||
}
|
||||
.quote-mode-opt span:hover { color: var(--bs-body-color); }
|
||||
.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>
|
||||
<link rel="stylesheet" href="~/css/item-wizard.css">
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
@@ -681,7 +551,7 @@
|
||||
<script src="~/lib/tom-select/js/tom-select.complete.min.js"></script>
|
||||
<script src="~/js/item-wizard.js?v=@DateTime.Now.Ticks"></script>
|
||||
<script>
|
||||
// ── Quick / Full quote mode toggle ──────────────────────────────────
|
||||
// ── Quick / Full quote mode toggle ──────────────────────────────────
|
||||
(function () {
|
||||
const STORAGE_KEY = 'pcl_quote_mode';
|
||||
const form = document.getElementById('quoteForm');
|
||||
@@ -734,6 +604,8 @@
|
||||
if (taxField) {
|
||||
taxField.value = exemptIds.has(customerId) ? 0 : (meta.companyTaxPercent ?? meta.taxPercent);
|
||||
}
|
||||
// Recalculate the live preview so it reflects the updated tax rate immediately
|
||||
if (typeof scheduleAutoPricing === 'function') scheduleAutoPricing();
|
||||
|
||||
const noEmail = customerId > 0 && noEmailIds.has(customerId);
|
||||
const emailSection = document.getElementById('emailNotifySection');
|
||||
@@ -803,52 +675,6 @@
|
||||
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
|
||||
document.getElementById('quoteForm').addEventListener('submit', function(e) {
|
||||
if (typeof quoteItems === 'undefined' || quoteItems.length === 0) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model PowderCoating.Application.DTOs.Quote.UpdateQuoteDto
|
||||
@model PowderCoating.Application.DTOs.Quote.UpdateQuoteDto
|
||||
@using PowderCoating.Core.Entities
|
||||
|
||||
@{
|
||||
@@ -109,7 +109,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right"
|
||||
data-bs-title="Quote Information"
|
||||
data-bs-content="Set the quote date, expiration, and any internal notes. The <strong>Expiration Date</strong> is shown to the customer — once it passes the quote is flagged Expired and can no longer be approved without editing. The <strong>Customer PO</strong> field is optional — use it if the customer provides their own purchase order number.<br><br><a href='/Help/Quotes#quote-statuses' target='_blank'>Learn more →</a>">
|
||||
data-bs-content="Set the quote date, expiration, and any internal notes. The <strong>Expiration Date</strong> is shown to the customer — once it passes the quote is flagged Expired and can no longer be approved without editing. The <strong>Customer PO</strong> field is optional — use it if the customer provides their own purchase order number.<br><br><a href='/Help/Quotes#quote-statuses' target='_blank'>Learn more →</a>">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</h5>
|
||||
@@ -216,7 +216,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right"
|
||||
data-bs-title="Quote Item Types"
|
||||
data-bs-content="<strong>Calculated</strong> — you enter surface area (sq ft) and the system prices it using your rates for materials, labour, and overhead.<br><strong>Custom Work</strong> — you enter a description and a manual price. Use this for flat-rate jobs or work that doesn't fit the formula.<br><strong>AI Photo</strong> — upload photos and let the AI estimate surface area and complexity for you.<br><br><a href='/Help/Quotes#quote-items' target='_blank'>Learn more →</a>">
|
||||
data-bs-content="<strong>Calculated</strong> — you enter surface area (sq ft) and the system prices it using your rates for materials, labour, and overhead.<br><strong>Custom Work</strong> — you enter a description and a manual price. Use this for flat-rate jobs or work that doesn't fit the formula.<br><strong>AI Photo</strong> — upload photos and let the AI estimate surface area and complexity for you.<br><br><a href='/Help/Quotes#quote-items' target='_blank'>Learn more →</a>">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</h5>
|
||||
@@ -292,7 +292,7 @@
|
||||
<a tabindex="0" class="help-icon text-white" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="left"
|
||||
data-bs-title="Pricing Summary"
|
||||
data-bs-content="The total is built up from materials, labour, equipment time, overhead, and profit margin — all based on the rates in Settings. A <strong>Tier Discount</strong> appears automatically if the customer has a pricing tier assigned. A <strong>Rush Fee</strong> is added when Rush Job is checked.<br><br><a href='/Help/Quotes#pricing-breakdown' target='_blank'>Learn more →</a>">
|
||||
data-bs-content="The total is built up from materials, labour, equipment time, overhead, and profit margin — all based on the rates in Settings. A <strong>Tier Discount</strong> appears automatically if the customer has a pricing tier assigned. A <strong>Rush Fee</strong> is added when Rush Job is checked.<br><br><a href='/Help/Quotes#pricing-breakdown' target='_blank'>Learn more →</a>">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</h5>
|
||||
@@ -459,99 +459,8 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Surface Area Calculator Modal -->
|
||||
<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 (@(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>
|
||||
@await Html.PartialAsync("_SqFtCalculatorModal")
|
||||
@await Html.PartialAsync("_ItemWizardModal")
|
||||
|
||||
<!-- Embedded data for JS -->
|
||||
@if (ViewBag.InventoryCoatings != null)
|
||||
@@ -647,43 +556,7 @@
|
||||
</script>
|
||||
|
||||
@section Styles {
|
||||
<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; }
|
||||
[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>
|
||||
<link rel="stylesheet" href="~/css/item-wizard.css">
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
@@ -758,51 +631,6 @@
|
||||
}
|
||||
|
||||
// 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
|
||||
document.getElementById('quoteForm').addEventListener('submit', function(e) {
|
||||
if (typeof quoteItems === 'undefined' || quoteItems.length === 0) {
|
||||
|
||||
@@ -38,6 +38,96 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var t in Model)
|
||||
{
|
||||
var isOverdueRT = t.IsActive && t.NextFireDate.Date < DateTime.Today;
|
||||
<div class="mobile-data-card">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);">
|
||||
<i class="bi bi-arrow-repeat"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@t.Name</h6>
|
||||
<small>
|
||||
@if (t.TemplateType == RecurringTemplateType.Bill)
|
||||
{
|
||||
<span>Bill</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Expense</span>
|
||||
}
|
||||
—
|
||||
@(t.IntervalCount == 1 ? t.Frequency.ToString() : $"Every {t.IntervalCount} × {t.Frequency}")
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value">
|
||||
@if (t.IsActive)
|
||||
{
|
||||
<span class="badge bg-success"><i class="bi bi-play-fill me-1"></i>Active</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary"><i class="bi bi-pause-fill me-1"></i>Paused</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
@if (t.IsActive)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Next Fire</span>
|
||||
<span class="mobile-card-value @(isOverdueRT ? "text-danger fw-semibold" : "")">
|
||||
@t.NextFireDate.ToString("MM/dd/yyyy")
|
||||
@if (isOverdueRT) { <i class="bi bi-exclamation-circle ms-1"></i> }
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Occurrences</span>
|
||||
<span class="mobile-card-value">
|
||||
@t.OccurrenceCount
|
||||
@if (t.MaxOccurrences.HasValue) { <span class="text-muted"> / @t.MaxOccurrences</span> }
|
||||
</span>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(t.LastError))
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Error</span>
|
||||
<span class="mobile-card-value text-danger small">@t.LastError</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-action="Edit" asp-route-id="@t.Id" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<form asp-action="ToggleActive" asp-route-id="@t.Id" method="post" style="display:inline;">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-sm @(t.IsActive ? "btn-outline-warning" : "btn-outline-success")">
|
||||
<i class="bi @(t.IsActive ? "bi-pause" : "bi-play")"></i>
|
||||
</button>
|
||||
</form>
|
||||
@if (t.IsActive)
|
||||
{
|
||||
<form asp-action="GenerateNow" asp-route-id="@t.Id" method="post" style="display:inline;"
|
||||
onsubmit="return confirm('Generate one occurrence now?')">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-lightning-charge"></i>
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead class="table-light">
|
||||
|
||||
@@ -65,6 +65,77 @@
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var note in Model)
|
||||
{
|
||||
<div class="mobile-data-card">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #4f46e5 0%, #3730a3 100%);">
|
||||
<i class="bi bi-journal-text"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6><code>v@(note.Version)</code> — @note.Title</h6>
|
||||
<small>
|
||||
<span class="badge @TagBadge(note.Tag)">@note.Tag</span>
|
||||
|
||||
@if (note.IsPublished)
|
||||
{
|
||||
<span class="badge bg-success">Published</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-warning text-dark">Draft</span>
|
||||
}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Released</span>
|
||||
<span class="mobile-card-value text-muted">@note.ReleasedAt.ToString("MM/dd/yyyy")</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Created By</span>
|
||||
<span class="mobile-card-value text-muted">@note.CreatedByUserName</span>
|
||||
</div>
|
||||
@if (note.Body.Length > 0)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Preview</span>
|
||||
<span class="mobile-card-value text-muted">@(note.Body.Length > 60 ? note.Body[..60] + "…" : note.Body)</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-action="Edit" asp-route-id="@note.Id" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<form asp-action="TogglePublish" asp-route-id="@note.Id" method="post" class="d-inline">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-sm @(note.IsPublished ? "btn-outline-warning" : "btn-outline-success")">
|
||||
<i class="bi @(note.IsPublished ? "bi-eye-slash" : "bi-eye")"></i>
|
||||
</button>
|
||||
</form>
|
||||
<form asp-action="Delete" asp-route-id="@note.Id" method="post" class="d-inline"
|
||||
onsubmit="return confirm('Delete v@(note.Version)?')">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (!Model.Any())
|
||||
{
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-journal-x fs-1 d-block mb-2 opacity-25"></i>
|
||||
No release notes yet. <a asp-action="Create">Create the first one.</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0 small">
|
||||
<thead class="table-light">
|
||||
|
||||
@@ -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>
|
||||
@@ -59,16 +59,19 @@
|
||||
|
||||
</div>
|
||||
|
||||
@* Inactivity timer — redirect to Welcome after 5 minutes of no input *@
|
||||
@* Inactivity timer — redirect to Welcome when idle too long.
|
||||
Intake steps set ViewBag.InactivityTimeoutMs = 45000 (45 s).
|
||||
Welcome screen keeps the default 5-minute timeout. *@
|
||||
@{
|
||||
bool showInactivityTimer = (bool)(ViewBag.ShowInactivityTimer ?? true);
|
||||
string welcomeUrl = ViewBag.WelcomeUrl as string ?? "/Kiosk/Welcome";
|
||||
int inactivityMs = ViewBag.InactivityTimeoutMs as int? ?? (5 * 60 * 1000);
|
||||
}
|
||||
@if (showInactivityTimer)
|
||||
{
|
||||
<script>
|
||||
(function () {
|
||||
var TIMEOUT_MS = 5 * 60 * 1000;
|
||||
var TIMEOUT_MS = @inactivityMs;
|
||||
var timer;
|
||||
function reset() {
|
||||
clearTimeout(timer);
|
||||
@@ -85,7 +88,6 @@
|
||||
}
|
||||
|
||||
<script src="~/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="~/lib/microsoft/signalr/dist/browser/signalr.min.js"></script>
|
||||
@await RenderSectionAsync("Scripts", required: false)
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1914,7 +1914,8 @@
|
||||
const icons = {
|
||||
QuoteApproved: { icon: 'bi-check-circle-fill', cls: 'success', title: 'Quote Approved' },
|
||||
QuoteDeclined: { icon: 'bi-x-circle-fill', cls: 'danger', title: 'Quote Declined' },
|
||||
InvoicePaid: { icon: 'bi-cash-coin', cls: 'primary', title: 'Payment Received' }
|
||||
InvoicePaid: { icon: 'bi-cash-coin', cls: 'primary', title: 'Payment Received' },
|
||||
KioskConsent: { icon: 'bi-chat-fill', cls: 'success', title: 'SMS Consent' }
|
||||
};
|
||||
const t = icons[data.notificationType] || { icon: 'bi-bell', cls: 'info', title: 'Notification' };
|
||||
toastr[t.cls === 'danger' ? 'warning' : t.cls === 'primary' ? 'info' : 'success'](
|
||||
@@ -1922,6 +1923,12 @@
|
||||
`<i class="bi ${t.icon} me-1"></i>${t.title}`,
|
||||
{ timeOut: 10000, extendedTimeOut: 3000, closeButton: true, enableHtml: true }
|
||||
);
|
||||
if (data.notificationType === 'KioskConsent' && data.customerId) {
|
||||
const path = window.location.pathname.toLowerCase();
|
||||
if (path === `/customers/details/${data.customerId}`) {
|
||||
window.updateCustomerSmsStatus?.();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
connection.start().catch(err => console.warn('SignalR connection failed:', err));
|
||||
@@ -2101,8 +2108,14 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Load on page ready
|
||||
document.addEventListener('DOMContentLoaded', load);
|
||||
// Load on page ready and refresh when dropdown is opened
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
load();
|
||||
btn?.addEventListener('show.bs.dropdown', load);
|
||||
});
|
||||
|
||||
// Fallback poll every 60 s in case SignalR misses a push
|
||||
setInterval(load, 60_000);
|
||||
|
||||
return { addItem, incrementBadge, markAllRead, openDetail, markRead };
|
||||
})();
|
||||
|
||||
@@ -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 × W ÷ @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>
|
||||
@@ -112,6 +112,101 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var row in Model)
|
||||
{
|
||||
<div class="mobile-data-card">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);">
|
||||
<i class="bi bi-building"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>
|
||||
@row.CompanyName
|
||||
@if (row.IsDeleted) { <span class="badge bg-secondary ms-1">Deleted</span> }
|
||||
</h6>
|
||||
<small>
|
||||
@if (row.SmsDisabledByAdmin)
|
||||
{
|
||||
<span class="text-danger"><i class="bi bi-slash-circle me-1"></i>Admin-Disabled</span>
|
||||
}
|
||||
else if (row.SmsEnabled)
|
||||
{
|
||||
<span class="text-success"><i class="bi bi-chat-dots me-1"></i>SMS Enabled</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">SMS Off</span>
|
||||
}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Terms</span>
|
||||
<span class="mobile-card-value">
|
||||
@{
|
||||
var dispAgreement = row.CurrentAgreement ?? row.LatestAgreement;
|
||||
}
|
||||
@if (row.CurrentAgreement != null)
|
||||
{
|
||||
<span class="badge bg-success">v@(row.CurrentAgreement.TermsVersion)</span>
|
||||
}
|
||||
else if (row.LatestAgreement != null)
|
||||
{
|
||||
<span class="badge bg-warning text-dark">Stale (v@(row.LatestAgreement.TermsVersion))</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-light text-muted border">Never</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
@if (dispAgreement != null)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Accepted By</span>
|
||||
<span class="mobile-card-value @(row.CurrentAgreement == null ? "text-muted" : "")">
|
||||
@dispAgreement.AgreedByUserName
|
||||
</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Accepted At</span>
|
||||
<span class="mobile-card-value @(row.CurrentAgreement == null ? "text-muted" : "")">
|
||||
@dispAgreement.AgreedAt.ToString("MM/dd/yy")
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@if (row.AllAgreements.Count > 0)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">History</span>
|
||||
<span class="mobile-card-value">
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#historyModal"
|
||||
data-company="@row.CompanyName"
|
||||
data-history="@System.Text.Json.JsonSerializer.Serialize(row.AllAgreements.Select(a => new {
|
||||
a.TermsVersion,
|
||||
a.AgreedByUserName,
|
||||
a.AgreedByUserId,
|
||||
AgreedAt = a.AgreedAt.ToString("MMM d, yyyy 'at' h:mm tt") + " UTC",
|
||||
IpAddress = a.IpAddress ?? "—",
|
||||
UserAgent = a.UserAgent ?? "—"
|
||||
}), new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase })"
|
||||
onclick="event.stopPropagation()">
|
||||
@row.AllAgreements.Count <i class="bi bi-clock-history ms-1"></i>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
|
||||
@@ -110,6 +110,64 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var row in Model.Rows)
|
||||
{
|
||||
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", "Customers", new { id = row.CustomerId })'">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #ec4899 0%, #be185d 100%);">
|
||||
<i class="bi bi-phone-vibrate"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@row.CustomerName</h6>
|
||||
<small>@(row.MobilePhone ?? row.Phone ?? "No phone")</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">SMS Status</span>
|
||||
<span class="mobile-card-value"><span class="badge @row.StatusBadgeClass">@row.StatusLabel</span></span>
|
||||
</div>
|
||||
@if (row.ConsentedAt.HasValue)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Consented</span>
|
||||
<span class="mobile-card-value">@row.ConsentedAt.Value.ToString("MMM d, yyyy")</span>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(row.ConsentMethod))
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Method</span>
|
||||
<span class="mobile-card-value">@row.ConsentMethod</span>
|
||||
</div>
|
||||
}
|
||||
@if (row.OptedOutAt.HasValue)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Opted Out</span>
|
||||
<span class="mobile-card-value text-danger">@row.OptedOutAt.Value.ToString("MMM d, yyyy")</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-controller="Customers" asp-action="Details" asp-route-id="@row.CustomerId"
|
||||
class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
|
||||
<i class="bi bi-person me-1"></i>Customer
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (!Model.Rows.Any())
|
||||
{
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-phone-vibrate fs-1 d-block mb-2 opacity-25"></i>
|
||||
No customers match the current filter.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle">
|
||||
<thead class="table-light">
|
||||
|
||||
@@ -84,6 +84,46 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var file in Model.Files.OrderBy(f => f.Status).ThenBy(f => f.RelativePath))
|
||||
{
|
||||
var fStatusBadge = file.Status switch
|
||||
{
|
||||
MigrationFileStatus.Migrated => "bg-success",
|
||||
MigrationFileStatus.Skipped => "bg-secondary",
|
||||
_ => "bg-danger"
|
||||
};
|
||||
var fStatusLabel = file.Status switch
|
||||
{
|
||||
MigrationFileStatus.Migrated => "Migrated",
|
||||
MigrationFileStatus.Skipped => "Already in Azure",
|
||||
_ => "Failed"
|
||||
};
|
||||
<div class="mobile-data-card">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #0369a1 0%, #075985 100%);">
|
||||
<i class="bi bi-file-earmark"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6 class="font-monospace" style="font-size:.75rem;">@file.RelativePath</h6>
|
||||
<small><span class="badge bg-light text-dark border">@file.Container</span></small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Size</span>
|
||||
<span class="mobile-card-value text-muted">@FormatBytes(file.FileSize)</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value"><span class="badge @fStatusBadge">@fStatusLabel</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead>
|
||||
|
||||
@@ -68,6 +68,64 @@
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var vc in Model)
|
||||
{
|
||||
var (vcBadge, vcLabel) = vc.Status switch
|
||||
{
|
||||
VendorCreditStatus.Open => ("bg-success", "Open"),
|
||||
VendorCreditStatus.PartiallyApplied => ("bg-warning text-dark", "Partial"),
|
||||
VendorCreditStatus.Applied => ("bg-secondary", "Applied"),
|
||||
VendorCreditStatus.Voided => ("bg-danger", "Voided"),
|
||||
_ => ("bg-secondary", vc.Status.ToString())
|
||||
};
|
||||
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", new { id = vc.Id })'">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #10b981 0%, #059669 100%);">
|
||||
<i class="bi bi-credit-card"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@vc.CreditNumber</h6>
|
||||
<small>@vc.Vendor?.CompanyName</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value"><span class="badge @vcBadge">@vcLabel</span></span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Date</span>
|
||||
<span class="mobile-card-value">@vc.CreditDate.ToString("MM/dd/yy")</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Total</span>
|
||||
<span class="mobile-card-value">@vc.Total.ToString("C")</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Remaining</span>
|
||||
<span class="mobile-card-value @(vc.RemainingAmount > 0 ? "text-success fw-semibold" : "text-muted")">
|
||||
@(vc.RemainingAmount > 0 ? vc.RemainingAmount.ToString("C") : "—")
|
||||
</span>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(vc.Memo))
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Memo</span>
|
||||
<span class="mobile-card-value">@vc.Memo</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-action="Details" asp-route-id="@vc.Id" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
|
||||
View
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
|
||||
@@ -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); }
|
||||
@@ -60,6 +60,14 @@ body.kiosk-body {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Vertically centre content in any tall kiosk button (covers <a> and <button>) */
|
||||
.kiosk-body .btn,
|
||||
.kiosk-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Suppress all hover effects on touch screens */
|
||||
@media (hover: none) {
|
||||
.kiosk-body .btn:hover { filter: none; opacity: 1; }
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
/* Hide desktop table view on mobile */
|
||||
.table-responsive {
|
||||
/* Hide desktop table only when a mobile card view sibling is present */
|
||||
.mobile-card-view ~ .table-responsive,
|
||||
.table-responsive:has(~ .mobile-card-view) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"use strict";
|
||||
|
||||
async function pushSmsConsent(customerId) {
|
||||
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
try {
|
||||
const res = await fetch(`/Kiosk/PushSmsConsent?customerId=${customerId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'RequestVerificationToken': tok }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
toastr.success('Consent form sent to the kiosk tablet — hand it to the customer.', 'Sent to Kiosk');
|
||||
document.getElementById('btnGetSmsConsent')?.classList.add('d-none');
|
||||
document.getElementById('btnCancelSmsConsent')?.classList.remove('d-none');
|
||||
} else {
|
||||
toastr.warning(data.message || 'Could not send consent to kiosk.');
|
||||
}
|
||||
} catch {
|
||||
toastr.error('An error occurred. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelSmsConsent() {
|
||||
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
try {
|
||||
const res = await fetch('/Kiosk/CancelSmsConsent', {
|
||||
method: 'POST',
|
||||
headers: { 'RequestVerificationToken': tok }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
toastr.info('Consent request cancelled — kiosk is free.');
|
||||
document.getElementById('btnCancelSmsConsent')?.classList.add('d-none');
|
||||
document.getElementById('btnGetSmsConsent')?.classList.remove('d-none');
|
||||
}
|
||||
} catch {
|
||||
toastr.error('An error occurred. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
window.updateCustomerSmsStatus = function () {
|
||||
const section = document.getElementById('sms-status-section');
|
||||
if (!section) return;
|
||||
const today = new Date().toLocaleDateString('en-US', { month: '2-digit', day: '2-digit', year: 'numeric' });
|
||||
section.innerHTML = `<span class="badge bg-success bg-opacity-10 text-success border border-success border-opacity-25"
|
||||
title="Consented ${today}">
|
||||
<i class="bi bi-chat-fill me-1"></i>SMS on
|
||||
</span>`;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user