Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a947494cbd | |||
| 7e79a13cb1 | |||
| 2ad6df1195 | |||
| dc3cd75ea4 | |||
| a73f14fa7f | |||
| 0af31c39b3 | |||
| e1256503be | |||
| b69ff6db3a | |||
| 66231822af | |||
| d5ad9fa073 |
@@ -59,6 +59,9 @@ public class CompanyPreferencesDto
|
|||||||
// Blank Work Order PDF Template
|
// Blank Work Order PDF Template
|
||||||
public string WoAccentColor { get; set; } = "#374151";
|
public string WoAccentColor { get; set; } = "#374151";
|
||||||
public string? WoTerms { get; set; }
|
public string? WoTerms { get; set; }
|
||||||
|
|
||||||
|
// Kiosk settings
|
||||||
|
public string KioskIntakeOutput { get; set; } = "Quote";
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UpdateAppDefaultsDto
|
public class UpdateAppDefaultsDto
|
||||||
@@ -136,3 +139,11 @@ public class UpdateWorkOrderTemplateDto
|
|||||||
public string WoAccentColor { get; set; } = "#374151";
|
public string WoAccentColor { get; set; } = "#374151";
|
||||||
[StringLength(2000)] public string? WoTerms { get; set; }
|
[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";
|
||||||
|
}
|
||||||
|
|||||||
@@ -76,12 +76,13 @@ public class KioskSessionListDto
|
|||||||
public DateTime ExpiresAt { get; set; }
|
public DateTime ExpiresAt { get; set; }
|
||||||
public int? LinkedCustomerId { get; set; }
|
public int? LinkedCustomerId { get; set; }
|
||||||
public int? LinkedJobId { get; set; }
|
public int? LinkedJobId { get; set; }
|
||||||
|
public int? LinkedQuoteId { get; set; }
|
||||||
public string? RemoteLinkEmail { get; set; }
|
public string? RemoteLinkEmail { get; set; }
|
||||||
|
|
||||||
public string CustomerFullName => $"{CustomerFirstName} {CustomerLastName}".Trim();
|
public string CustomerFullName => $"{CustomerFirstName} {CustomerLastName}".Trim();
|
||||||
public string JobDescriptionSnippet =>
|
public string JobDescriptionSnippet =>
|
||||||
JobDescription.Length > 80 ? JobDescription[..80] + "…" : JobDescription;
|
JobDescription.Length > 80 ? JobDescription[..80] + "…" : JobDescription;
|
||||||
public bool IsConverted => LinkedJobId.HasValue;
|
public bool IsConverted => LinkedJobId.HasValue || LinkedQuoteId.HasValue;
|
||||||
public bool IsExpired => Status == KioskSessionStatus.Expired ||
|
public bool IsExpired => Status == KioskSessionStatus.Expired ||
|
||||||
(Status == KioskSessionStatus.Active && DateTime.UtcNow > ExpiresAt);
|
(Status == KioskSessionStatus.Active && DateTime.UtcNow > ExpiresAt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,5 +54,6 @@ public class CompanyProfile : Profile
|
|||||||
CreateMap<UpdateQuoteTemplateDto, CompanyPreferences>();
|
CreateMap<UpdateQuoteTemplateDto, CompanyPreferences>();
|
||||||
CreateMap<UpdateInvoiceTemplateDto, CompanyPreferences>();
|
CreateMap<UpdateInvoiceTemplateDto, CompanyPreferences>();
|
||||||
CreateMap<UpdateWorkOrderTemplateDto, CompanyPreferences>();
|
CreateMap<UpdateWorkOrderTemplateDto, CompanyPreferences>();
|
||||||
|
CreateMap<UpdateKioskSettingsDto, CompanyPreferences>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,14 @@ public class CompanyPreferences : BaseEntity
|
|||||||
/// <summary>JSON blob persisting QB Migration Wizard step state across sessions.</summary>
|
/// <summary>JSON blob persisting QB Migration Wizard step state across sessions.</summary>
|
||||||
public string? QbMigrationStateJson { get; set; }
|
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
|
// Guided activation / first-workflow onboarding
|
||||||
/// <summary>Selected first-workflow path: quote_first or job_first. Null until chosen.</summary>
|
/// <summary>Selected first-workflow path: quote_first or job_first. Null until chosen.</summary>
|
||||||
public string? OnboardingPath { get; set; }
|
public string? OnboardingPath { get; set; }
|
||||||
|
|||||||
@@ -36,7 +36,10 @@ public class KioskSession : BaseEntity
|
|||||||
|
|
||||||
// ── Outcome ───────────────────────────────────────────────────────────────
|
// ── Outcome ───────────────────────────────────────────────────────────────
|
||||||
public int? LinkedCustomerId { get; set; }
|
public int? LinkedCustomerId { get; set; }
|
||||||
|
/// <summary>Set when KioskIntakeOutput = "Job". Null when a Quote was created instead.</summary>
|
||||||
public int? LinkedJobId { get; set; }
|
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; }
|
public DateTime? SubmittedAt { get; set; }
|
||||||
/// <summary>Sessions auto-expire 2 h after creation (InPerson) or 48 h (Remote). ExpiresAt is set at creation.</summary>
|
/// <summary>Sessions auto-expire 2 h after creation (InPerson) or 48 h (Remote). ExpiresAt is set at creation.</summary>
|
||||||
public DateTime ExpiresAt { get; set; }
|
public DateTime ExpiresAt { get; set; }
|
||||||
|
|||||||
@@ -31,10 +31,13 @@ public interface ICompanyListService
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns a paged, searched, and sorted slice of non-deleted companies together with the
|
/// 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>
|
/// </summary>
|
||||||
Task<(List<Company> Companies, int TotalCount)> GetPagedAsync(
|
Task<(List<Company> Companies, int TotalCount, int ChurnedCount)> GetPagedAsync(
|
||||||
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize);
|
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize,
|
||||||
|
bool hideChurned = true);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns job, quote, customer, and wizard completion counts for each of the supplied
|
/// 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2253,6 +2253,10 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<int>("JobRetentionYears")
|
b.Property<int>("JobRetentionYears")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("KioskIntakeOutput")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<int>("LogRetentionDays")
|
b.Property<int>("LogRetentionDays")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -5637,6 +5641,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<int?>("LinkedJobId")
|
b.Property<int?>("LinkedJobId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int?>("LinkedQuoteId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<string>("RemoteLinkEmail")
|
b.Property<string>("RemoteLinkEmail")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -6692,7 +6699,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4259),
|
CreatedAt = new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2349),
|
||||||
Description = "Standard pricing for regular customers",
|
Description = "Standard pricing for regular customers",
|
||||||
DiscountPercent = 0m,
|
DiscountPercent = 0m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6703,7 +6710,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4264),
|
CreatedAt = new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2366),
|
||||||
Description = "5% discount for preferred customers",
|
Description = "5% discount for preferred customers",
|
||||||
DiscountPercent = 5m,
|
DiscountPercent = 5m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6714,7 +6721,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4266),
|
CreatedAt = new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2367),
|
||||||
Description = "10% discount for premium customers",
|
Description = "10% discount for premium customers",
|
||||||
DiscountPercent = 10m,
|
DiscountPercent = 10m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using PowderCoating.Core.Entities;
|
using PowderCoating.Core.Entities;
|
||||||
|
using PowderCoating.Core.Enums;
|
||||||
using PowderCoating.Core.Interfaces.Services;
|
using PowderCoating.Core.Interfaces.Services;
|
||||||
using PowderCoating.Infrastructure.Data;
|
using PowderCoating.Infrastructure.Data;
|
||||||
|
|
||||||
@@ -21,15 +22,34 @@ public class CompanyListService : ICompanyListService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<(List<Company> Companies, int TotalCount)> GetPagedAsync(
|
public async Task<(List<Company> Companies, int TotalCount, int ChurnedCount)> GetPagedAsync(
|
||||||
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize)
|
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
|
var query = _context.Companies
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.Where(c => !c.IsDeleted)
|
.Where(c => !c.IsDeleted)
|
||||||
.AsQueryable();
|
.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))
|
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||||
{
|
{
|
||||||
var s = searchTerm.ToLower();
|
var s = searchTerm.ToLower();
|
||||||
@@ -61,7 +81,7 @@ public class CompanyListService : ICompanyListService
|
|||||||
.Take(pageSize)
|
.Take(pageSize)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
return (companies, totalCount);
|
return (companies, totalCount, churnedCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
|
|||||||
@@ -66,15 +66,16 @@ public class CompaniesController : Controller
|
|||||||
string sortColumn = "CompanyName",
|
string sortColumn = "CompanyName",
|
||||||
string sortDirection = "asc",
|
string sortDirection = "asc",
|
||||||
int pageNumber = 1,
|
int pageNumber = 1,
|
||||||
int pageSize = 25)
|
int pageSize = 25,
|
||||||
|
bool showChurned = false)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
pageNumber = Math.Max(1, pageNumber);
|
pageNumber = Math.Max(1, pageNumber);
|
||||||
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
|
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
|
||||||
|
|
||||||
var (companies, totalCount) = await _companyList.GetPagedAsync(
|
var (companies, totalCount, churnedCount) = await _companyList.GetPagedAsync(
|
||||||
searchTerm, sortColumn, sortDirection, pageNumber, pageSize);
|
searchTerm, sortColumn, sortDirection, pageNumber, pageSize, hideChurned: !showChurned);
|
||||||
|
|
||||||
var companyDtos = _mapper.Map<List<CompanyListDto>>(companies);
|
var companyDtos = _mapper.Map<List<CompanyListDto>>(companies);
|
||||||
|
|
||||||
@@ -128,6 +129,8 @@ public class CompaniesController : Controller
|
|||||||
ViewBag.PageSize = pageSize;
|
ViewBag.PageSize = pageSize;
|
||||||
ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
||||||
ViewBag.ImpersonatingCompanyId = HttpContext.Session.GetInt32("ImpersonatingCompanyId");
|
ViewBag.ImpersonatingCompanyId = HttpContext.Session.GetInt32("ImpersonatingCompanyId");
|
||||||
|
ViewBag.ShowChurned = showChurned;
|
||||||
|
ViewBag.ChurnedCount = churnedCount;
|
||||||
|
|
||||||
return View(companyDtos);
|
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.
|
/// user's risk/search filters, so the KPI cards always show platform-wide totals.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </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 now = DateTime.UtcNow;
|
||||||
var d30 = now.AddDays(-30);
|
var d30 = now.AddDays(-30);
|
||||||
var d90 = now.AddDays(-90);
|
var d90 = now.AddDays(-90);
|
||||||
|
var churnedCutoff = now.AddDays(-14);
|
||||||
|
|
||||||
// One query per signal — all keyed by CompanyId
|
// One query per signal — all keyed by CompanyId
|
||||||
var companies = await _db.Companies
|
var allCompanies = await _db.Companies
|
||||||
.AsNoTracking().IgnoreQueryFilters()
|
.AsNoTracking().IgnoreQueryFilters()
|
||||||
.Where(c => !c.IsDeleted)
|
.Where(c => !c.IsDeleted)
|
||||||
.ToListAsync();
|
.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
|
var lastLogins = await _db.Users
|
||||||
.AsNoTracking().IgnoreQueryFilters()
|
.AsNoTracking().IgnoreQueryFilters()
|
||||||
.Where(u => u.LastLoginDate != null)
|
.Where(u => u.LastLoginDate != null)
|
||||||
@@ -163,6 +175,8 @@ public class CompanyHealthController : Controller
|
|||||||
ViewBag.Risk = risk;
|
ViewBag.Risk = risk;
|
||||||
ViewBag.Search = search;
|
ViewBag.Search = search;
|
||||||
ViewBag.ConfigIssuesOnly = configIssuesOnly;
|
ViewBag.ConfigIssuesOnly = configIssuesOnly;
|
||||||
|
ViewBag.ShowChurned = showChurned;
|
||||||
|
ViewBag.ChurnedCount = churnedCount;
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(search))
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
all = all.Where(h =>
|
all = all.Where(h =>
|
||||||
|
|||||||
@@ -543,6 +543,15 @@ public class CompanySettingsController : Controller
|
|||||||
public Task<IActionResult> UpdateWorkOrderTemplate([FromBody] UpdateWorkOrderTemplateDto dto) =>
|
public Task<IActionResult> UpdateWorkOrderTemplate([FromBody] UpdateWorkOrderTemplateDto dto) =>
|
||||||
UpdatePreferences(dto, "Work order settings saved successfully.");
|
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>
|
/// <summary>
|
||||||
/// Persists the company's pricing model parameters — labor rates, sandblasting/masking multipliers,
|
/// 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 —
|
/// oven cost per hour, overhead admin/facility percentages, profit margin, and default tax rate —
|
||||||
|
|||||||
@@ -304,6 +304,32 @@ public class InventoryController : Controller
|
|||||||
await _unitOfWork.SaveChangesAsync();
|
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.";
|
TempData["Success"] = "Inventory item created successfully.";
|
||||||
return RedirectToAction(nameof(Details), new { id = item.Id });
|
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." });
|
return Json(new { success = false, errorMessage = "No product URL provided." });
|
||||||
|
|
||||||
var result = await _aiLookupService.LookupByUrlAsync(productUrl, colorName);
|
var result = await _aiLookupService.LookupByUrlAsync(productUrl, colorName);
|
||||||
|
if (result.Success)
|
||||||
|
await EnrichFromCatalogAsync(result, autoContribute: true);
|
||||||
return Json(result);
|
return Json(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -750,6 +778,39 @@ public class InventoryController : Controller
|
|||||||
result.SdsUrl ??= match.SdsUrl;
|
result.SdsUrl ??= match.SdsUrl;
|
||||||
result.TdsUrl ??= match.TdsUrl;
|
result.TdsUrl ??= match.TdsUrl;
|
||||||
if (match.UnitPrice > 0) result.UnitCostPerLb ??= match.UnitPrice;
|
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);
|
return (true, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -767,6 +828,7 @@ public class InventoryController : Controller
|
|||||||
VendorName = manufacturer,
|
VendorName = manufacturer,
|
||||||
Sku = sku,
|
Sku = sku,
|
||||||
ColorName = colorName,
|
ColorName = colorName,
|
||||||
|
UnitPrice = result.UnitCostPerLb ?? 0m,
|
||||||
CureTemperatureF = result.CureTemperatureF,
|
CureTemperatureF = result.CureTemperatureF,
|
||||||
CureTimeMinutes = result.CureTimeMinutes,
|
CureTimeMinutes = result.CureTimeMinutes,
|
||||||
Finish = result.Finish,
|
Finish = result.Finish,
|
||||||
@@ -1050,61 +1112,50 @@ public class InventoryController : Controller
|
|||||||
.Select(i => i.ManufacturerPartNumber!.Trim().ToLower())
|
.Select(i => i.ManufacturerPartNumber!.Trim().ToLower())
|
||||||
.ToHashSet();
|
.ToHashSet();
|
||||||
|
|
||||||
// When a vendor is specified, search vendor-scoped first. Only widen to all vendors
|
// Single query — all partial color/SKU matches across all vendors.
|
||||||
// if the scoped search returns nothing — prevents a cross-vendor color match from
|
// Results are ranked: exact vendor + exact color (isExact=true) sorts first and
|
||||||
// being returned as the only result when the user clearly intended a specific manufacturer.
|
// triggers auto-fill in the JS. Everything else goes to the picker modal.
|
||||||
IEnumerable<PowderCatalogItem> matches;
|
// This means a user who typed "Columbia Coatings" + "Lime Green" gets auto-fill
|
||||||
if (!string.IsNullOrEmpty(vendorTerm))
|
// 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.
|
||||||
matches = await _unitOfWork.PowderCatalog.FindAsync(p =>
|
var matches = await _unitOfWork.PowderCatalog.FindAsync(p =>
|
||||||
p.VendorName.ToLower().Contains(vendorTerm) && (
|
|
||||||
p.Sku.ToLower() == term ||
|
|
||||||
p.ColorName.ToLower().Contains(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.Sku.ToLower() == term ||
|
||||||
p.ColorName.ToLower().Contains(term) ||
|
|
||||||
p.Sku.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));
|
|
||||||
}
|
|
||||||
|
|
||||||
var results = matches
|
var results = matches
|
||||||
.Where(p => !existingSkus.Contains(p.Sku.ToLower()))
|
.Where(p => !existingSkus.Contains(p.Sku.ToLower()))
|
||||||
.OrderBy(p => p.Sku.ToLower() == term ? 0 : 1)
|
.Select(p =>
|
||||||
.ThenBy(p => p.ColorName)
|
|
||||||
.Select(p => new
|
|
||||||
{
|
{
|
||||||
id = p.Id,
|
var vendorMatch = string.IsNullOrEmpty(vendorTerm) || p.VendorName.ToLower().Contains(vendorTerm);
|
||||||
vendorName = p.VendorName,
|
var colorExact = p.ColorName.ToLower() == term;
|
||||||
sku = p.Sku,
|
return (p, isExact: vendorMatch && colorExact, vendorMatch, colorExact);
|
||||||
colorName = p.ColorName,
|
})
|
||||||
description = p.Description,
|
.OrderBy(x => x.isExact ? 0 : x.vendorMatch ? 1 : x.colorExact ? 2 : 3)
|
||||||
unitPrice = p.UnitPrice,
|
.ThenBy(x => x.p.ColorName)
|
||||||
imageUrl = p.ImageUrl,
|
.Select(x => new
|
||||||
sdsUrl = p.SdsUrl,
|
{
|
||||||
tdsUrl = p.TdsUrl,
|
id = x.p.Id,
|
||||||
applicationGuideUrl = p.ApplicationGuideUrl,
|
vendorName = x.p.VendorName,
|
||||||
productUrl = p.ProductUrl,
|
sku = x.p.Sku,
|
||||||
isDiscontinued = p.IsDiscontinued,
|
colorName = x.p.ColorName,
|
||||||
cureTemperatureF = p.CureTemperatureF,
|
description = x.p.Description,
|
||||||
cureTimeMinutes = p.CureTimeMinutes,
|
unitPrice = x.p.UnitPrice,
|
||||||
finish = p.Finish,
|
imageUrl = x.p.ImageUrl,
|
||||||
colorFamilies = p.ColorFamilies,
|
sdsUrl = x.p.SdsUrl,
|
||||||
requiresClearCoat = p.RequiresClearCoat,
|
tdsUrl = x.p.TdsUrl,
|
||||||
coverageSqFtPerLb = p.CoverageSqFtPerLb,
|
applicationGuideUrl = x.p.ApplicationGuideUrl,
|
||||||
specificGravity = p.SpecificGravity,
|
productUrl = x.p.ProductUrl,
|
||||||
transferEfficiency = GetEffectiveTransferEfficiency(p.TransferEfficiency)
|
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();
|
.ToList();
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using AutoMapper;
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using PowderCoating.Application.DTOs.Kiosk;
|
using PowderCoating.Application.DTOs.Kiosk;
|
||||||
using PowderCoating.Application.Interfaces;
|
using PowderCoating.Application.Interfaces;
|
||||||
@@ -39,6 +40,9 @@ public class KioskController : Controller
|
|||||||
private readonly IHubContext<KioskHub> _kioskHub;
|
private readonly IHubContext<KioskHub> _kioskHub;
|
||||||
private readonly ILogger<KioskController> _logger;
|
private readonly ILogger<KioskController> _logger;
|
||||||
private readonly ICompanyLogoService _logoService;
|
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>
|
/// <summary>Initialises all dependencies for the kiosk controller.</summary>
|
||||||
public KioskController(
|
public KioskController(
|
||||||
@@ -49,7 +53,8 @@ public class KioskController : Controller
|
|||||||
IEmailService emailService,
|
IEmailService emailService,
|
||||||
IHubContext<KioskHub> kioskHub,
|
IHubContext<KioskHub> kioskHub,
|
||||||
ILogger<KioskController> logger,
|
ILogger<KioskController> logger,
|
||||||
ICompanyLogoService logoService)
|
ICompanyLogoService logoService,
|
||||||
|
IMemoryCache cache)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_mapper = mapper;
|
_mapper = mapper;
|
||||||
@@ -59,6 +64,7 @@ public class KioskController : Controller
|
|||||||
_kioskHub = kioskHub;
|
_kioskHub = kioskHub;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_logoService = logoService;
|
_logoService = logoService;
|
||||||
|
_cache = cache;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -104,6 +110,10 @@ public class KioskController : Controller
|
|||||||
if (company == null || company.KioskActivationToken != cookie.Value.token)
|
if (company == null || company.KioskActivationToken != cookie.Value.token)
|
||||||
return Json(new { hasSession = false });
|
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 window = DateTime.UtcNow.AddSeconds(-60);
|
||||||
var session = await _unitOfWork.KioskSessions.FirstOrDefaultAsync(
|
var session = await _unitOfWork.KioskSessions.FirstOrDefaultAsync(
|
||||||
s => s.CompanyId == cookie.Value.companyId
|
s => s.CompanyId == cookie.Value.companyId
|
||||||
@@ -116,6 +126,116 @@ public class KioskController : Controller
|
|||||||
return Json(new { hasSession = true, sessionToken = session.SessionToken });
|
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>
|
/// <summary>
|
||||||
/// Serves the company logo for anonymous kiosk pages. Resolves the company from the
|
/// 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.
|
/// KioskDevice cookie so no tenant context is needed on the anonymous request.
|
||||||
@@ -483,6 +603,7 @@ public class KioskController : Controller
|
|||||||
ExpiresAt = s.ExpiresAt,
|
ExpiresAt = s.ExpiresAt,
|
||||||
LinkedCustomerId = s.LinkedCustomerId,
|
LinkedCustomerId = s.LinkedCustomerId,
|
||||||
LinkedJobId = s.LinkedJobId,
|
LinkedJobId = s.LinkedJobId,
|
||||||
|
LinkedQuoteId = s.LinkedQuoteId,
|
||||||
RemoteLinkEmail = s.RemoteLinkEmail
|
RemoteLinkEmail = s.RemoteLinkEmail
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -590,9 +711,45 @@ public class KioskController : Controller
|
|||||||
: "RemoteIntake";
|
: "RemoteIntake";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Create Job in Pending status with Normal priority
|
// 3. Resolve company preference: create a Quote (default) or a Job
|
||||||
var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
|
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
|
||||||
var pendingStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Pending);
|
p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true);
|
||||||
|
var intakeOutput = prefs?.KioskIntakeOutput ?? "Quote";
|
||||||
|
var createQuote = !string.Equals(intakeOutput, "Job", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
session.LinkedCustomerId = customer!.Id;
|
||||||
|
|
||||||
|
if (createQuote)
|
||||||
|
{
|
||||||
|
// 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.");
|
||||||
|
|
||||||
|
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)
|
||||||
|
};
|
||||||
|
|
||||||
|
await _unitOfWork.Quotes.AddAsync(quote);
|
||||||
|
await _unitOfWork.CompleteAsync(); // quote.Id now valid
|
||||||
|
|
||||||
|
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)
|
if (pendingStatus == null)
|
||||||
throw new InvalidOperationException($"No Pending job status found for company {companyId}. Run Seed Data from Platform Management.");
|
throw new InvalidOperationException($"No Pending job status found for company {companyId}. Run Seed Data from Platform Management.");
|
||||||
|
|
||||||
@@ -606,7 +763,7 @@ public class KioskController : Controller
|
|||||||
var job = new Job
|
var job = new Job
|
||||||
{
|
{
|
||||||
CompanyId = companyId,
|
CompanyId = companyId,
|
||||||
CustomerId = customer!.Id,
|
CustomerId = customer.Id,
|
||||||
JobNumber = jobNumber,
|
JobNumber = jobNumber,
|
||||||
JobStatusId = pendingStatus.Id,
|
JobStatusId = pendingStatus.Id,
|
||||||
JobPriorityId = normalPriority.Id,
|
JobPriorityId = normalPriority.Id,
|
||||||
@@ -615,30 +772,56 @@ public class KioskController : Controller
|
|||||||
};
|
};
|
||||||
|
|
||||||
await _unitOfWork.Jobs.AddAsync(job);
|
await _unitOfWork.Jobs.AddAsync(job);
|
||||||
|
await _unitOfWork.CompleteAsync(); // job.Id now valid
|
||||||
|
|
||||||
// Save the job first so EF generates its Id, then link the session.
|
|
||||||
// Setting session.LinkedJobId = job.Id before CompleteAsync would write 0
|
|
||||||
// to the FK column because the DB hasn't assigned the Id yet.
|
|
||||||
await _unitOfWork.CompleteAsync(); // job.Id is now valid
|
|
||||||
|
|
||||||
// 4. Update session links now that both Ids exist
|
|
||||||
session.LinkedCustomerId = customer.Id;
|
|
||||||
session.LinkedJobId = job.Id;
|
session.LinkedJobId = job.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Persist session links
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
// 5. Fire staff notification
|
// 5. Fire staff notification
|
||||||
var jobDesc = session.JobDescription ?? "";
|
var jobDesc = session.JobDescription ?? "";
|
||||||
var snippet = jobDesc.Length > 60 ? jobDesc[..60] + "…" : jobDesc;
|
var snippet = jobDesc.Length > 60 ? jobDesc[..60] + "…" : jobDesc;
|
||||||
var fullName = $"{session.CustomerFirstName} {session.CustomerLastName}".Trim();
|
var fullName = $"{session.CustomerFirstName} {session.CustomerLastName}".Trim();
|
||||||
|
var intakeLabel = session.SessionType == KioskSessionType.Remote ? "Remote Intake" : "Walk-in Intake";
|
||||||
await _inApp.CreateAsync(
|
await _inApp.CreateAsync(
|
||||||
companyId,
|
companyId,
|
||||||
"Walk-in Intake Submitted",
|
$"{intakeLabel} Submitted",
|
||||||
$"{fullName} completed their intake form — {snippet}",
|
$"{fullName} completed their intake form — {snippet}",
|
||||||
"KioskIntake",
|
"KioskIntake",
|
||||||
link: $"/Kiosk/Intakes",
|
link: $"/Kiosk/Intakes",
|
||||||
customerId: customer.Id);
|
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>
|
/// <summary>
|
||||||
/// Generates the next sequential job number using the company's configured prefix.
|
/// Generates the next sequential job number using the company's configured prefix.
|
||||||
/// Mirrors the logic in JobsController.GenerateJobNumber() — same format: PREFIX-YYMM-####.
|
/// Mirrors the logic in JobsController.GenerateJobNumber() — same format: PREFIX-YYMM-####.
|
||||||
@@ -716,7 +899,11 @@ public class KioskController : Controller
|
|||||||
? Url.Action("Logo", "Kiosk")
|
? Url.Action("Logo", "Kiosk")
|
||||||
: null;
|
: null;
|
||||||
ViewBag.WelcomeUrl = "/Kiosk/Welcome";
|
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>
|
/// <summary>Loads the company from a session's CompanyId and populates ViewBag.</summary>
|
||||||
|
|||||||
@@ -1270,7 +1270,11 @@ public static class HelpKnowledgeBase
|
|||||||
|
|
||||||
**Where:** Kiosk Setup → [/Kiosk/Activate](/Kiosk/Activate) | Intake Sessions → [/Kiosk/Intakes](/Kiosk/Intakes)
|
**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 Pending Job and Customer record are auto-created 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.
|
**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):**
|
**Setup (one-time per device):**
|
||||||
1. Go to Settings → Kiosk Setup (or /Kiosk/Activate)
|
1. Go to Settings → Kiosk Setup (or /Kiosk/Activate)
|
||||||
@@ -1293,23 +1297,24 @@ public static class HelpKnowledgeBase
|
|||||||
|
|
||||||
**What happens on submission:**
|
**What happens on submission:**
|
||||||
- Customer is matched by email (first), then phone; if no match, a new non-commercial customer is created
|
- Customer is matched by email (first), then phone; if no match, a new non-commercial customer is created
|
||||||
- A Pending job (Normal priority) is created with the customer's description as the Job Description
|
- 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
|
- SMS opt-in updates the customer record with NotifyBySms = true and a TCPA-compliant consent timestamp
|
||||||
- In-app notification fires: "Walk-in Intake Submitted" with link to /Kiosk/Intakes
|
- 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):**
|
**Reviewing submissions (Intake Sessions page):**
|
||||||
- Filter tabs: All / Submitted / Pending / Expired
|
- Filter tabs: All / Submitted / Pending / Expired
|
||||||
- Each row shows customer name, phone, email, job description snippet, session type badge, SMS opt-in icon
|
- Each row shows customer name, phone, email, job description snippet, session type badge, SMS opt-in icon
|
||||||
- "View Job" button → opens the auto-created Pending job so staff can quote, assign, and move it through the workflow
|
- "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
|
- "Customer" button → opens the matched/created customer record
|
||||||
- If job creation failed (e.g. seed data not run), the session is still marked Submitted but the buttons won't appear — the raw intake data is still visible so staff can create manually
|
- 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.
|
**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:**
|
**Troubleshooting:**
|
||||||
- "Connection issue — retrying…" on tablet: Wi-Fi problem; dot auto-recovers when connectivity returns
|
- "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
|
- Tablet doesn't respond to Start Intake: waits up to 3 s; reload Welcome page if still stuck
|
||||||
- No View Job button after submission: Seed Data not run — Platform Admin must run it from Platform Management → Seed Data
|
- 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
|
- 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
|
- AI quote times out on mobile: photos are auto-compressed; "Still analyzing…" message appears after 30 s; retry on stronger connection
|
||||||
|
|
||||||
@@ -1329,11 +1334,14 @@ public static class HelpKnowledgeBase
|
|||||||
**Prospect to customer:**
|
**Prospect to customer:**
|
||||||
Create Quote for prospect → Quote Approved → Convert Prospect to Customer → Convert Quote to Job
|
Create Quote for prospect → Quote Approved → Convert Prospect to Customer → Convert Quote to Job
|
||||||
|
|
||||||
**Walk-in customer intake (kiosk):**
|
**Walk-in customer intake (kiosk — Quote mode):**
|
||||||
Staff clicks "Start Intake" on Dashboard → tablet Welcome screen navigates to intake form within 3 s → customer fills out 3 steps (contact, job description, terms + signature) → system creates Customer + Pending Job automatically → staff notification fires → staff reviews at /Kiosk/Intakes → clicks "View Job" to open the job and continue the workflow
|
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):**
|
**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
|
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):**
|
**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
|
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
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ public class InAppNotificationService : IInAppNotificationService
|
|||||||
message = notification.Message,
|
message = notification.Message,
|
||||||
link = notification.Link,
|
link = notification.Link,
|
||||||
notificationType = notification.NotificationType,
|
notificationType = notification.NotificationType,
|
||||||
|
customerId = notification.CustomerId,
|
||||||
createdAt = now.ToString("o")
|
createdAt = now.ToString("o")
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,11 +26,13 @@
|
|||||||
var totalPages = (int)(ViewBag.TotalPages ?? 1);
|
var totalPages = (int)(ViewBag.TotalPages ?? 1);
|
||||||
var totalCount = (int)(ViewBag.TotalCount ?? 0);
|
var totalCount = (int)(ViewBag.TotalCount ?? 0);
|
||||||
var impersonatingId = (int?)(ViewBag.ImpersonatingCompanyId);
|
var impersonatingId = (int?)(ViewBag.ImpersonatingCompanyId);
|
||||||
|
var showChurned = (bool)(ViewBag.ShowChurned ?? false);
|
||||||
|
var churnedCount = (int)(ViewBag.ChurnedCount ?? 0);
|
||||||
|
|
||||||
string SortLink(string col)
|
string SortLink(string col)
|
||||||
{
|
{
|
||||||
var dir = (sortColumn == col && sortDirection == "asc") ? "desc" : "asc";
|
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)
|
string SortIcon(string col)
|
||||||
@@ -54,6 +56,7 @@
|
|||||||
<input type="hidden" name="sortColumn" value="@sortColumn" />
|
<input type="hidden" name="sortColumn" value="@sortColumn" />
|
||||||
<input type="hidden" name="sortDirection" value="@sortDirection" />
|
<input type="hidden" name="sortDirection" value="@sortDirection" />
|
||||||
<input type="hidden" name="pageSize" value="@pageSize" />
|
<input type="hidden" name="pageSize" value="@pageSize" />
|
||||||
|
<input type="hidden" name="showChurned" value="@showChurned.ToString().ToLower()" />
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||||
@@ -75,6 +78,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 shadow-sm">
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
@if (Model != null && Model.Any())
|
@if (Model != null && Model.Any())
|
||||||
@@ -313,18 +335,18 @@
|
|||||||
<nav>
|
<nav>
|
||||||
<ul class="pagination pagination-sm mb-0">
|
<ul class="pagination pagination-sm mb-0">
|
||||||
<li class="page-item @(pageNumber == 1 ? "disabled" : "")">
|
<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>
|
<i class="bi bi-chevron-left"></i>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@for (int p = Math.Max(1, pageNumber - 2); p <= Math.Min(totalPages, pageNumber + 2); p++)
|
@for (int p = Math.Max(1, pageNumber - 2); p <= Math.Min(totalPages, pageNumber + 2); p++)
|
||||||
{
|
{
|
||||||
<li class="page-item @(p == pageNumber ? "active" : "")">
|
<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>
|
||||||
}
|
}
|
||||||
<li class="page-item @(pageNumber == totalPages ? "disabled" : "")">
|
<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>
|
<i class="bi bi-chevron-right"></i>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -464,6 +486,7 @@
|
|||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
url.searchParams.set('pageSize', size);
|
url.searchParams.set('pageSize', size);
|
||||||
url.searchParams.set('pageNumber', '1');
|
url.searchParams.set('pageNumber', '1');
|
||||||
|
url.searchParams.set('showChurned', '@showChurned.ToString().ToLower()');
|
||||||
window.location.href = url.toString();
|
window.location.href = url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Company Health";
|
ViewData["Title"] = "Company Health";
|
||||||
|
|
||||||
|
var showChurned = (bool)(ViewBag.ShowChurned ?? false);
|
||||||
|
var churnedCount = (int)(ViewBag.ChurnedCount ?? 0);
|
||||||
|
|
||||||
string RiskBadge(ChurnRisk r) => r switch {
|
string RiskBadge(ChurnRisk r) => r switch {
|
||||||
ChurnRisk.Healthy => "bg-success",
|
ChurnRisk.Healthy => "bg-success",
|
||||||
ChurnRisk.AtRisk => "bg-warning text-dark",
|
ChurnRisk.AtRisk => "bg-warning text-dark",
|
||||||
@@ -73,6 +76,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 *@
|
@* Summary stat cards *@
|
||||||
<div class="row g-3 mb-3">
|
<div class="row g-3 mb-3">
|
||||||
<div class="col-6 col-lg-3">
|
<div class="col-6 col-lg-3">
|
||||||
@@ -193,6 +216,7 @@
|
|||||||
<label class="form-check-label small" for="configOnly">Config issues only</label>
|
<label class="form-check-label small" for="configOnly">Config issues only</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<input type="hidden" name="showChurned" value="@showChurned.ToString().ToLower()" />
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<button class="btn btn-sm btn-primary">Filter</button>
|
<button class="btn btn-sm btn-primary">Filter</button>
|
||||||
<a asp-action="Index" class="btn btn-sm btn-outline-secondary ms-1">Clear</a>
|
<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-retention">Data Retention</option>
|
||||||
<option value="data-lookups">Data Lookups</option>
|
<option value="data-lookups">Data Lookups</option>
|
||||||
<option value="pdf-templates">PDF Templates</option>
|
<option value="pdf-templates">PDF Templates</option>
|
||||||
|
<option value="kiosk">Kiosk</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -100,6 +101,11 @@
|
|||||||
</button>
|
</button>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
|
|
||||||
<!-- Tabs Content -->
|
<!-- Tabs Content -->
|
||||||
@@ -1978,6 +1984,67 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -3248,12 +3315,41 @@
|
|||||||
else showError(data.message);
|
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
|
// Auto-open online-payments tab if redirected with ?tab=online-payments
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
if (urlParams.get('tab') === 'online-payments') {
|
if (urlParams.get('tab') === 'online-payments') {
|
||||||
const btn = document.querySelector('[data-bs-target="#online-payments"]');
|
const btn = document.querySelector('[data-bs-target="#online-payments"]');
|
||||||
if (btn) new bootstrap.Tab(btn).show();
|
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>
|
</script>
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,9 +173,11 @@
|
|||||||
<i class="bi bi-envelope-slash me-1"></i>Email off
|
<i class="bi bi-envelope-slash me-1"></i>Email off
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
<span id="sms-status-section">
|
||||||
@if (Model.NotifyBySms)
|
@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
|
<i class="bi bi-chat-fill me-1"></i>SMS on
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@@ -184,7 +186,22 @@
|
|||||||
<span class="badge bg-secondary bg-opacity-10 text-secondary border border-secondary border-opacity-25">
|
<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
|
<i class="bi bi-chat-slash me-1"></i>SMS off
|
||||||
</span>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -543,3 +560,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script src="~/js/customer-details.js" asp-append-version="true"></script>
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,9 @@
|
|||||||
</h2>
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
The Customer Intake Kiosk lets walk-in customers fill out their own intake form on a front-desk tablet
|
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>Pending job</strong> and a
|
— no staff assistance required. When they're done, a <strong>customer record</strong> is automatically
|
||||||
<strong>customer record</strong> are automatically created, and your team receives an in-app
|
created (or matched to an existing one), a <strong>Draft Quote or Pending Job</strong> is created
|
||||||
notification so they know someone is waiting.
|
depending on your setting, and your team receives an in-app notification.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
The kiosk runs as a browser page (optimised for iPad and Android tablets) and can also send a
|
The kiosk runs as a browser page (optimised for iPad and Android tablets) and can also send a
|
||||||
@@ -89,6 +89,28 @@
|
|||||||
</ol>
|
</ol>
|
||||||
</section>
|
</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">
|
<section id="what-happens" class="mb-5">
|
||||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
<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
|
<i class="bi bi-arrow-right-circle text-primary me-2"></i>What Happens on Submission
|
||||||
@@ -96,9 +118,18 @@
|
|||||||
<p>When a customer submits their intake form, the system automatically:</p>
|
<p>When a customer submits their intake form, the system automatically:</p>
|
||||||
<ul>
|
<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>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 Pending Job</strong> — Normal priority, with the customer's description as the job description and the intake source noted in Special Instructions.</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>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" with a link to the Intakes page.</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>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -116,14 +147,15 @@
|
|||||||
<li>Job description snippet</li>
|
<li>Job description snippet</li>
|
||||||
<li>Session type (In-Person or Remote) and status badge</li>
|
<li>Session type (In-Person or Remote) and status badge</li>
|
||||||
<li>SMS opt-in indicator</li>
|
<li>SMS opt-in indicator</li>
|
||||||
<li><strong>View Job</strong> button — opens the auto-created job directly so you can add a quote, assign a worker, or update status</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>
|
<li><strong>Customer</strong> button — opens the matched or created customer record</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="alert alert-info alert-permanent">
|
<div class="alert alert-info alert-permanent">
|
||||||
<i class="bi bi-info-circle me-2"></i>
|
<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
|
If submission failed (e.g. a configuration issue), the session is still marked Submitted but the
|
||||||
View Job / Customer buttons won't appear. The raw intake data (name, phone, description) is still
|
action buttons won't appear. The raw intake data (name, phone, description) is still
|
||||||
visible so staff can create the job manually.
|
visible so staff can create the record manually.
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -166,6 +198,7 @@
|
|||||||
<a class="nav-link py-1 px-3" href="#overview">Overview</a>
|
<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="#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="#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="#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="#reviewing">Reviewing Submissions</a>
|
||||||
<a class="nav-link py-1 px-3" href="#troubleshooting">Troubleshooting</a>
|
<a class="nav-link py-1 px-3" href="#troubleshooting">Troubleshooting</a>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
ViewData["Title"] = "Terms & Consent";
|
ViewData["Title"] = "Terms & Consent";
|
||||||
var token = ViewBag.SessionToken as Guid? ?? Guid.Empty;
|
var token = ViewBag.SessionToken as Guid? ?? Guid.Empty;
|
||||||
bool isInPerson = ViewBag.IsInPerson as bool? ?? false;
|
bool isInPerson = ViewBag.IsInPerson as bool? ?? false;
|
||||||
|
bool quoteFirst = !string.Equals(ViewBag.KioskIntakeOutput as string, "Job", StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="kiosk-card">
|
<div class="kiosk-card">
|
||||||
@@ -25,10 +26,20 @@
|
|||||||
have authority to authorize work on them. You release the shop from liability for
|
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.
|
pre-existing damage, hidden defects, or items left unclaimed after 30 days.
|
||||||
</p>
|
</p>
|
||||||
|
@if (quoteFirst)
|
||||||
|
{
|
||||||
<p>
|
<p>
|
||||||
Final pricing is subject to a formal quote. Work will not begin until you approve
|
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.
|
the quoted amount. Payment is due upon pickup unless otherwise agreed in writing.
|
||||||
</p>
|
</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">
|
<p class="mb-0">
|
||||||
You agree to comply with all pickup and payment terms provided by the shop.
|
You agree to comply with all pickup and payment terms provided by the shop.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -146,6 +146,12 @@
|
|||||||
<i class="bi bi-briefcase me-1"></i>View Job
|
<i class="bi bi-briefcase me-1"></i>View Job
|
||||||
</a>
|
</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>View Quote
|
||||||
|
</a>
|
||||||
|
}
|
||||||
@if (s.LinkedCustomerId.HasValue)
|
@if (s.LinkedCustomerId.HasValue)
|
||||||
{
|
{
|
||||||
<a href="/Customers/Details/@s.LinkedCustomerId" class="btn btn-sm btn-outline-primary">
|
<a href="/Customers/Details/@s.LinkedCustomerId" class="btn btn-sm btn-outline-primary">
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1914,7 +1914,8 @@
|
|||||||
const icons = {
|
const icons = {
|
||||||
QuoteApproved: { icon: 'bi-check-circle-fill', cls: 'success', title: 'Quote Approved' },
|
QuoteApproved: { icon: 'bi-check-circle-fill', cls: 'success', title: 'Quote Approved' },
|
||||||
QuoteDeclined: { icon: 'bi-x-circle-fill', cls: 'danger', title: 'Quote Declined' },
|
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' };
|
const t = icons[data.notificationType] || { icon: 'bi-bell', cls: 'info', title: 'Notification' };
|
||||||
toastr[t.cls === 'danger' ? 'warning' : t.cls === 'primary' ? 'info' : 'success'](
|
toastr[t.cls === 'danger' ? 'warning' : t.cls === 'primary' ? 'info' : 'success'](
|
||||||
@@ -1922,6 +1923,12 @@
|
|||||||
`<i class="bi ${t.icon} me-1"></i>${t.title}`,
|
`<i class="bi ${t.icon} me-1"></i>${t.title}`,
|
||||||
{ timeOut: 10000, extendedTimeOut: 3000, closeButton: true, enableHtml: true }
|
{ 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));
|
connection.start().catch(err => console.warn('SignalR connection failed:', err));
|
||||||
@@ -2101,8 +2108,14 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load on page ready
|
// Load on page ready and refresh when dropdown is opened
|
||||||
document.addEventListener('DOMContentLoaded', load);
|
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 };
|
return { addItem, incrementBadge, markAllRead, openDetail, markRead };
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ body.kiosk-body {
|
|||||||
width: 100%;
|
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 */
|
/* Suppress all hover effects on touch screens */
|
||||||
@media (hover: none) {
|
@media (hover: none) {
|
||||||
.kiosk-body .btn:hover { filter: none; opacity: 1; }
|
.kiosk-body .btn:hover { filter: none; opacity: 1; }
|
||||||
|
|||||||
@@ -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>`;
|
||||||
|
};
|
||||||
@@ -62,23 +62,25 @@
|
|||||||
const items = await resp.json();
|
const items = await resp.json();
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
// No catalog match — fall back to AI if available
|
// Nothing in catalog — go straight to AI
|
||||||
hideStatus();
|
await runAiOrWarn();
|
||||||
if (typeof window._runInventoryAiLookup === 'function') {
|
|
||||||
showStatus('info', '<span class="spinner-border spinner-border-sm me-1"></span>Not in catalog — searching with AI…');
|
|
||||||
await window._runInventoryAiLookup();
|
|
||||||
} else {
|
|
||||||
showStatus('warning', 'No match found in the catalog. Enter details manually or enable AI Lookup.');
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (items.length === 1) {
|
// Single exact match (vendor + color name both match precisely) — auto-fill
|
||||||
|
if (items.length === 1 && items[0].isExact) {
|
||||||
await fillFields(items[0]);
|
await fillFields(items[0]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multiple matches — let the user pick via modal
|
// Exact match exists but so do other results — auto-fill the exact one
|
||||||
|
const exactMatches = items.filter(i => i.isExact);
|
||||||
|
if (exactMatches.length === 1) {
|
||||||
|
await fillFields(exactMatches[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No exact match (or ambiguous) — show picker modal with "Not Listed" escape hatch
|
||||||
hideStatus();
|
hideStatus();
|
||||||
showPickerModal(items);
|
showPickerModal(items);
|
||||||
|
|
||||||
@@ -89,6 +91,18 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── AI fallback helper ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function runAiOrWarn() {
|
||||||
|
hideStatus();
|
||||||
|
if (typeof window._runInventoryAiLookup === 'function') {
|
||||||
|
showStatus('info', '<span class="spinner-border spinner-border-sm me-1"></span>Not in catalog — searching online with AI…');
|
||||||
|
await window._runInventoryAiLookup();
|
||||||
|
} else {
|
||||||
|
showStatus('warning', 'No match found in the catalog. Enter details manually or enable AI Lookup.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Fill fields from a catalog result ────────────────────────────────────
|
// ── Fill fields from a catalog result ────────────────────────────────────
|
||||||
|
|
||||||
async function fillFields(item) {
|
async function fillFields(item) {
|
||||||
@@ -368,6 +382,12 @@
|
|||||||
<div class="modal-body p-0">
|
<div class="modal-body p-0">
|
||||||
<div class="list-group list-group-flush">${rows}</div>
|
<div class="list-group list-group-flush">${rows}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="modal-footer py-2 justify-content-start">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" id="catalogPickerNotListed">
|
||||||
|
<i class="bi bi-search me-1"></i>Not listed — search online
|
||||||
|
</button>
|
||||||
|
<span class="text-muted small ms-2">Uses AI to look up the exact product</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -383,6 +403,11 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('catalogPickerNotListed').addEventListener('click', function () {
|
||||||
|
bsModal.hide();
|
||||||
|
runAiOrWarn();
|
||||||
|
});
|
||||||
|
|
||||||
bsModal.show();
|
bsModal.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,12 @@
|
|||||||
if (!res.ok) throw new Error("HTTP " + res.status);
|
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setStatus("#16a34a", "Ready");
|
setStatus("#16a34a", "Ready");
|
||||||
|
if (data.smsConsentPending && data.customerId) {
|
||||||
|
active = false;
|
||||||
|
setStatus("#2563eb", "Loading consent…");
|
||||||
|
window.location.href = `/Kiosk/SmsConsent/${data.customerId}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (data.hasSession && data.sessionToken) {
|
if (data.hasSession && data.sessionToken) {
|
||||||
active = false;
|
active = false;
|
||||||
setStatus("#2563eb", "Starting…");
|
setStatus("#2563eb", "Starting…");
|
||||||
|
|||||||
Reference in New Issue
Block a user