Compare commits

...

10 Commits

Author SHA1 Message Date
spouliot a947494cbd Merge dev into master: churned account filter, powder catalog lookup fixes 2026-05-14 14:19:27 -04:00
spouliot 7e79a13cb1 Fix powder catalog lookup: exact match auto-fills, partials show picker modal
- CatalogLookup now returns all partial color name matches ranked by
  specificity (exact vendor+color first, same-vendor partial, cross-vendor)
  with isExact flag so JS can decide to auto-fill vs show modal
- Removed cross-vendor fallback that was silently overwriting manufacturer
  field with wrong brand when vendor-scoped search found nothing
- Picker modal now includes "Not listed — search online" option that
  triggers AI lookup as an escape hatch from the catalog results

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 14:18:52 -04:00
spouliot 2ad6df1195 Hide churned trial accounts from company/health screens by default
- Companies list and Company Health now hide Expired/Canceled accounts
  whose subscription ended 14+ days ago; show/hide toggle via banner
- KPI cards on Company Health exclude churned tenants when hidden
- showChurned param threads through sort, pagination, search, and filter forms
- Powder catalog: fix missing UnitPrice on user-contributed entries;
  add back-sync to fill catalog gaps on existing matches; wire
  AiAugmentFromUrl and manual inventory Create into catalog contribute path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 13:59:12 -04:00
spouliot dc3cd75ea4 Merge dev into master — prod deploy 2026-05-14
- Real-time SMS consent status update on customer record
- Fix kiosk SMS consent routing loop and stuck tablet
- Fix notification bell, SMS consent kiosk flow, and button alignment
- Add staff-presented SMS consent flow on customer record
- Customer intake kiosk (SignalR → polling, inactivity reset, signature pad, anonymous logo endpoint)
- Invoice SMS notifications
- Kiosk help article and AI knowledge base updates
2026-05-14 08:17:24 -04:00
spouliot a73f14fa7f Real-time SMS consent status update on customer record
When kiosk consent is completed, the staff-facing customer Details page
now updates the SMS badge instantly via SignalR — no page refresh needed.
Added customerId to the NewInAppNotification SignalR payload so the
KioskConsent handler can match the current URL and swap the badge in place.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 23:40:47 -04:00
spouliot 0af31c39b3 Fix kiosk SMS consent routing loop and stuck tablet
- Route param renamed customerId→id so /Kiosk/SmsConsent/15307 binds correctly
  (default MVC route uses {id}; mismatched name caused GetByIdAsync(0)→404→loop)
- Cache entry cleared in GET (not just POST) so returning to Welcome after seeing
  the form never redirects again
- Added POST /Kiosk/CancelSmsConsent for staff to free the kiosk if they pushed
  consent accidentally — Customer Details shows a Cancel button after pushing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 23:25:37 -04:00
spouliot e1256503be Fix notification bell, SMS consent kiosk flow, and button alignment
Notification bell:
- Bell now polls /InAppNotifications/Recent every 60s as a SignalR fallback
- Bell dropdown refresh on open so count is always current when staff looks at it

SMS consent → kiosk flow:
- Staff clicks "Get SMS Consent" on Customer Details → AJAX POST to
  /Kiosk/PushSmsConsent stores customer in IMemoryCache (10 min TTL)
- Kiosk PollSession returns smsConsentPending + customerId so tablet navigates
  to /Kiosk/SmsConsent/{customerId} automatically
- Customer reads TCPA consent on tablet, taps I Agree or No Thanks
- On agree: NotifyBySms/SmsConsentedAt/SmsConsentMethod set; in-app notification
  fires; cache cleared; tablet returns to Welcome
- Removed Customers/SmsConsent (staff-browser version); moved view to Kiosk/

Button alignment:
- kiosk.css: added display:flex + align-items:center + justify-content:center to
  all kiosk body buttons so content is centred vertically in tall button outlines

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 23:13:57 -04:00
spouliot b69ff6db3a Add staff-presented SMS consent flow on customer record
- New GET/POST Customers/SmsConsent/{id}: full-screen kiosk-layout page staff
  opens and hands to the customer to read TCPA consent language and tap I Agree
- On agreement: sets Customer.NotifyBySms, SmsConsentedAt (UTC), SmsConsentMethod
  = "InPerson", clears SmsOptedOutAt
- Redirects back if customer has already consented (no double-consent)
- Customer Details: "Get SMS Consent" badge link shown when NotifyBySms is false;
  SMS on badge shows consent date on hover when consented

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 22:50:49 -04:00
spouliot 66231822af Update kiosk help article and AI knowledge base for output setting
- Help article: new "Kiosk Output Setting" section explaining Quote vs Job modes and
  the Company Settings → Kiosk tab; Overview updated; Reviewing Submissions now lists
  "View Quote" and "View Job" separately; notification label corrected (Remote vs Walk-in)
- AI knowledge base: CUSTOMER INTAKE KIOSK section updated — output setting documented,
  submission outcome reflects Quote/Job branch, notification labels corrected, workflow
  entries split into Quote-mode and Job-mode variants, troubleshooting updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 22:39:39 -04:00
spouliot d5ad9fa073 Add KioskIntakeOutput company setting and fix kiosk submission bugs
- New CompanyPreferences.KioskIntakeOutput setting ("Quote" default / "Job"): controls
  what the kiosk creates on submission; shown as a card-style radio toggle in
  Company Settings → Kiosk tab
- KioskSession.LinkedQuoteId added so quote-first sessions link back to the draft quote
- Migration AddKioskIntakeOutputSetting applies both schema changes
- ProcessSubmissionAsync branches on setting: creates Draft quote (quote-first) or
  Pending job (job-first); save order fixed (CompleteAsync before using DB-assigned Id as FK)
- Terms.cshtml pricing paragraph is now dynamic: "subject to formal quote" for Quote mode,
  "team member will reach out about pricing" for Job mode
- Customer Intakes list: "View Quote" button appears when LinkedQuoteId is set
- Notification label fixed: Remote sessions now say "Remote Intake", not "Walk-in Intake"
- Inactivity reset shortened to 45 s on intake steps
- Signature pad: hosted locally (no CDN), canvas resize deferred via requestAnimationFrame
- AI photo upload: client-side compression to ≤1200px + AbortController 120 s timeout
- Help article and AI knowledge base updated with kiosk feature

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 22:35:37 -04:00
30 changed files with 11661 additions and 141 deletions
@@ -59,6 +59,9 @@ public class CompanyPreferencesDto
// Blank Work Order PDF Template
public string WoAccentColor { get; set; } = "#374151";
public string? WoTerms { get; set; }
// Kiosk settings
public string KioskIntakeOutput { get; set; } = "Quote";
}
public class UpdateAppDefaultsDto
@@ -136,3 +139,11 @@ public class UpdateWorkOrderTemplateDto
public string WoAccentColor { get; set; } = "#374151";
[StringLength(2000)] public string? WoTerms { get; set; }
}
public class UpdateKioskSettingsDto
{
/// <summary>"Quote" (default) or "Job" — what the kiosk creates on submission.</summary>
[Required]
public string KioskIntakeOutput { get; set; } = "Quote";
}
@@ -76,12 +76,13 @@ public class KioskSessionListDto
public DateTime ExpiresAt { get; set; }
public int? LinkedCustomerId { get; set; }
public int? LinkedJobId { get; set; }
public int? LinkedQuoteId { get; set; }
public string? RemoteLinkEmail { get; set; }
public string CustomerFullName => $"{CustomerFirstName} {CustomerLastName}".Trim();
public string JobDescriptionSnippet =>
JobDescription.Length > 80 ? JobDescription[..80] + "…" : JobDescription;
public bool IsConverted => LinkedJobId.HasValue;
public bool IsConverted => LinkedJobId.HasValue || LinkedQuoteId.HasValue;
public bool IsExpired => Status == KioskSessionStatus.Expired ||
(Status == KioskSessionStatus.Active && DateTime.UtcNow > ExpiresAt);
}
@@ -54,5 +54,6 @@ public class CompanyProfile : Profile
CreateMap<UpdateQuoteTemplateDto, CompanyPreferences>();
CreateMap<UpdateInvoiceTemplateDto, 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>
public string? QbMigrationStateJson { get; set; }
// Kiosk settings
/// <summary>
/// Controls what the kiosk creates on submission: "Quote" (default) or "Job".
/// Quote aligns with the default Terms text ("subject to a formal quote").
/// Job is for shops that price on the spot and want the work order ready immediately.
/// </summary>
public string KioskIntakeOutput { get; set; } = "Quote";
// Guided activation / first-workflow onboarding
/// <summary>Selected first-workflow path: quote_first or job_first. Null until chosen.</summary>
public string? OnboardingPath { get; set; }
@@ -36,7 +36,10 @@ public class KioskSession : BaseEntity
// ── Outcome ───────────────────────────────────────────────────────────────
public int? LinkedCustomerId { get; set; }
/// <summary>Set when KioskIntakeOutput = "Job". Null when a Quote was created instead.</summary>
public int? LinkedJobId { get; set; }
/// <summary>Set when KioskIntakeOutput = "Quote". Null when a Job was created instead.</summary>
public int? LinkedQuoteId { get; set; }
public DateTime? SubmittedAt { get; set; }
/// <summary>Sessions auto-expire 2 h after creation (InPerson) or 48 h (Remote). ExpiresAt is set at creation.</summary>
public DateTime ExpiresAt { get; set; }
@@ -31,10 +31,13 @@ public interface ICompanyListService
{
/// <summary>
/// Returns a paged, searched, and sorted slice of non-deleted companies together with the
/// total unfiltered count for pagination.
/// total count for pagination and the count of churned accounts that are currently hidden.
/// When <paramref name="hideChurned"/> is true, Expired/Canceled companies whose subscription
/// ended more than 14 days ago are excluded from results (but still counted for the banner).
/// </summary>
Task<(List<Company> Companies, int TotalCount)> GetPagedAsync(
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize);
Task<(List<Company> Companies, int TotalCount, int ChurnedCount)> GetPagedAsync(
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize,
bool hideChurned = true);
/// <summary>
/// Returns job, quote, customer, and wizard completion counts for each of the supplied
@@ -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")
.HasColumnType("int");
b.Property<string>("KioskIntakeOutput")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("LogRetentionDays")
.HasColumnType("int");
@@ -5637,6 +5641,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int?>("LinkedJobId")
.HasColumnType("int");
b.Property<int?>("LinkedQuoteId")
.HasColumnType("int");
b.Property<string>("RemoteLinkEmail")
.HasColumnType("nvarchar(max)");
@@ -6692,7 +6699,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4259),
CreatedAt = new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2349),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -6703,7 +6710,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4264),
CreatedAt = new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2366),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -6714,7 +6721,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4266),
CreatedAt = new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2367),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces.Services;
using PowderCoating.Infrastructure.Data;
@@ -21,15 +22,34 @@ public class CompanyListService : ICompanyListService
}
/// <inheritdoc/>
public async Task<(List<Company> Companies, int TotalCount)> GetPagedAsync(
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize)
public async Task<(List<Company> Companies, int TotalCount, int ChurnedCount)> GetPagedAsync(
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize,
bool hideChurned = true)
{
var cutoff = DateTime.UtcNow.AddDays(-14);
// Always count churned regardless of hideChurned so the banner can show a number.
var churnedCount = await _context.Companies
.AsNoTracking()
.IgnoreQueryFilters()
.Where(c => !c.IsDeleted
&& (c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled)
&& c.SubscriptionEndDate != null
&& c.SubscriptionEndDate < cutoff)
.CountAsync();
var query = _context.Companies
.AsNoTracking()
.IgnoreQueryFilters()
.Where(c => !c.IsDeleted)
.AsQueryable();
if (hideChurned)
query = query.Where(c =>
!((c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled)
&& c.SubscriptionEndDate != null
&& c.SubscriptionEndDate < cutoff));
if (!string.IsNullOrWhiteSpace(searchTerm))
{
var s = searchTerm.ToLower();
@@ -61,7 +81,7 @@ public class CompanyListService : ICompanyListService
.Take(pageSize)
.ToListAsync();
return (companies, totalCount);
return (companies, totalCount, churnedCount);
}
/// <inheritdoc/>
@@ -66,15 +66,16 @@ public class CompaniesController : Controller
string sortColumn = "CompanyName",
string sortDirection = "asc",
int pageNumber = 1,
int pageSize = 25)
int pageSize = 25,
bool showChurned = false)
{
try
{
pageNumber = Math.Max(1, pageNumber);
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
var (companies, totalCount) = await _companyList.GetPagedAsync(
searchTerm, sortColumn, sortDirection, pageNumber, pageSize);
var (companies, totalCount, churnedCount) = await _companyList.GetPagedAsync(
searchTerm, sortColumn, sortDirection, pageNumber, pageSize, hideChurned: !showChurned);
var companyDtos = _mapper.Map<List<CompanyListDto>>(companies);
@@ -128,6 +129,8 @@ public class CompaniesController : Controller
ViewBag.PageSize = pageSize;
ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
ViewBag.ImpersonatingCompanyId = HttpContext.Session.GetInt32("ImpersonatingCompanyId");
ViewBag.ShowChurned = showChurned;
ViewBag.ChurnedCount = churnedCount;
return View(companyDtos);
}
@@ -45,18 +45,30 @@ public class CompanyHealthController : Controller
/// user's risk/search filters, so the KPI cards always show platform-wide totals.
/// </para>
/// </summary>
public async Task<IActionResult> Index(string? risk, string? search, bool configIssuesOnly = false)
public async Task<IActionResult> Index(string? risk, string? search, bool configIssuesOnly = false, bool showChurned = false)
{
var now = DateTime.UtcNow;
var d30 = now.AddDays(-30);
var d90 = now.AddDays(-90);
var churnedCutoff = now.AddDays(-14);
// One query per signal — all keyed by CompanyId
var companies = await _db.Companies
var allCompanies = await _db.Companies
.AsNoTracking().IgnoreQueryFilters()
.Where(c => !c.IsDeleted)
.ToListAsync();
var churnedCount = allCompanies.Count(c =>
(c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled)
&& c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate.Value < churnedCutoff);
var companies = showChurned
? allCompanies
: allCompanies.Where(c =>
!((c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled)
&& c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate.Value < churnedCutoff))
.ToList();
var lastLogins = await _db.Users
.AsNoTracking().IgnoreQueryFilters()
.Where(u => u.LastLoginDate != null)
@@ -163,6 +175,8 @@ public class CompanyHealthController : Controller
ViewBag.Risk = risk;
ViewBag.Search = search;
ViewBag.ConfigIssuesOnly = configIssuesOnly;
ViewBag.ShowChurned = showChurned;
ViewBag.ChurnedCount = churnedCount;
if (!string.IsNullOrWhiteSpace(search))
all = all.Where(h =>
@@ -543,6 +543,15 @@ public class CompanySettingsController : Controller
public Task<IActionResult> UpdateWorkOrderTemplate([FromBody] UpdateWorkOrderTemplateDto dto) =>
UpdatePreferences(dto, "Work order settings saved successfully.");
/// <summary>
/// Saves kiosk intake output preference ("Quote" or "Job") to <see cref="CompanyPreferences"/>.
/// Delegates to <see cref="UpdatePreferences{TDto}"/>.
/// </summary>
// POST: CompanySettings/UpdateKioskSettings
[HttpPost]
public Task<IActionResult> UpdateKioskSettings([FromBody] UpdateKioskSettingsDto dto) =>
UpdatePreferences(dto, "Kiosk settings saved successfully.");
/// <summary>
/// Persists the company's pricing model parameters — labor rates, sandblasting/masking multipliers,
/// oven cost per hour, overhead admin/facility percentages, profit margin, and default tax rate —
@@ -304,6 +304,32 @@ public class InventoryController : Controller
await _unitOfWork.SaveChangesAsync();
}
// Contribute/sync to the platform powder catalog if we have enough identity data.
// Runs silently — a failure here never blocks the inventory save.
if (!string.IsNullOrWhiteSpace(dto.Manufacturer) && !string.IsNullOrWhiteSpace(dto.ManufacturerPartNumber))
{
var catalogResult = new InventoryAiLookupResult
{
Manufacturer = dto.Manufacturer,
ManufacturerPartNumber = dto.ManufacturerPartNumber,
ColorName = dto.ColorName ?? item.Name,
Finish = dto.Finish,
CureTemperatureF = dto.CureTemperatureF,
CureTimeMinutes = dto.CureTimeMinutes,
ColorFamilies = dto.ColorFamilies,
RequiresClearCoat = dto.RequiresClearCoat ? true : (bool?)null,
CoverageSqFtPerLb = dto.CoverageSqFtPerLb,
SpecificGravity = dto.SpecificGravity,
TransferEfficiency = dto.TransferEfficiency,
UnitCostPerLb = dto.UnitCost > 0 ? dto.UnitCost : null,
SpecPageUrl = dto.SpecPageUrl,
ImageUrl = dto.ImageUrl,
SdsUrl = dto.SdsUrl,
TdsUrl = dto.TdsUrl,
};
await EnrichFromCatalogAsync(catalogResult, autoContribute: true);
}
TempData["Success"] = "Inventory item created successfully.";
return RedirectToAction(nameof(Details), new { id = item.Id });
}
@@ -704,6 +730,8 @@ public class InventoryController : Controller
return Json(new { success = false, errorMessage = "No product URL provided." });
var result = await _aiLookupService.LookupByUrlAsync(productUrl, colorName);
if (result.Success)
await EnrichFromCatalogAsync(result, autoContribute: true);
return Json(result);
}
@@ -750,6 +778,39 @@ public class InventoryController : Controller
result.SdsUrl ??= match.SdsUrl;
result.TdsUrl ??= match.TdsUrl;
if (match.UnitPrice > 0) result.UnitCostPerLb ??= match.UnitPrice;
// Back-sync: fill NULL catalog fields from the incoming result so the catalog
// gets richer over time without overwriting anything already stored.
bool catalogDirty = false;
if (match.Finish == null && !string.IsNullOrWhiteSpace(result.Finish)) { match.Finish = result.Finish; catalogDirty = true; }
if (match.CureTemperatureF == null && result.CureTemperatureF != null) { match.CureTemperatureF = result.CureTemperatureF; catalogDirty = true; }
if (match.CureTimeMinutes == null && result.CureTimeMinutes != null) { match.CureTimeMinutes = result.CureTimeMinutes; catalogDirty = true; }
if (match.ColorFamilies == null && !string.IsNullOrWhiteSpace(result.ColorFamilies)){ match.ColorFamilies = result.ColorFamilies; catalogDirty = true; }
if (match.RequiresClearCoat == null && result.RequiresClearCoat != null) { match.RequiresClearCoat = result.RequiresClearCoat; catalogDirty = true; }
if (match.CoverageSqFtPerLb == null && result.CoverageSqFtPerLb != null) { match.CoverageSqFtPerLb = result.CoverageSqFtPerLb; catalogDirty = true; }
if (match.SpecificGravity == null && result.SpecificGravity != null) { match.SpecificGravity = result.SpecificGravity; catalogDirty = true; }
if (match.TransferEfficiency == null && result.TransferEfficiency != null) { match.TransferEfficiency = result.TransferEfficiency; catalogDirty = true; }
if (string.IsNullOrWhiteSpace(match.ImageUrl) && !string.IsNullOrWhiteSpace(result.ImageUrl)) { match.ImageUrl = result.ImageUrl; catalogDirty = true; }
if (string.IsNullOrWhiteSpace(match.ProductUrl) && !string.IsNullOrWhiteSpace(result.SpecPageUrl)){ match.ProductUrl = result.SpecPageUrl; catalogDirty = true; }
if (string.IsNullOrWhiteSpace(match.SdsUrl) && !string.IsNullOrWhiteSpace(result.SdsUrl)) { match.SdsUrl = result.SdsUrl; catalogDirty = true; }
if (string.IsNullOrWhiteSpace(match.TdsUrl) && !string.IsNullOrWhiteSpace(result.TdsUrl)) { match.TdsUrl = result.TdsUrl; catalogDirty = true; }
if (match.UnitPrice == 0 && (result.UnitCostPerLb ?? 0) > 0) { match.UnitPrice = result.UnitCostPerLb!.Value; catalogDirty = true; }
if (catalogDirty)
{
match.UpdatedAt = DateTime.UtcNow;
try
{
await _unitOfWork.PowderCatalog.UpdateAsync(match);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Back-synced catalog gaps for {VendorName} {Sku}", match.VendorName, match.Sku);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to back-sync catalog entry {Id}", match.Id);
}
}
return (true, false);
}
@@ -767,6 +828,7 @@ public class InventoryController : Controller
VendorName = manufacturer,
Sku = sku,
ColorName = colorName,
UnitPrice = result.UnitCostPerLb ?? 0m,
CureTemperatureF = result.CureTemperatureF,
CureTimeMinutes = result.CureTimeMinutes,
Finish = result.Finish,
@@ -1050,61 +1112,50 @@ public class InventoryController : Controller
.Select(i => i.ManufacturerPartNumber!.Trim().ToLower())
.ToHashSet();
// When a vendor is specified, search vendor-scoped first. Only widen to all vendors
// if the scoped search returns nothing — prevents a cross-vendor color match from
// being returned as the only result when the user clearly intended a specific manufacturer.
IEnumerable<PowderCatalogItem> matches;
if (!string.IsNullOrEmpty(vendorTerm))
{
matches = await _unitOfWork.PowderCatalog.FindAsync(p =>
p.VendorName.ToLower().Contains(vendorTerm) && (
p.Sku.ToLower() == term ||
p.ColorName.ToLower().Contains(term) ||
p.Sku.ToLower().Contains(term)));
// Fall back to all vendors only when the scoped search finds nothing
if (!matches.Any())
{
matches = await _unitOfWork.PowderCatalog.FindAsync(p =>
p.Sku.ToLower() == term ||
p.ColorName.ToLower().Contains(term) ||
p.Sku.ToLower().Contains(term));
}
}
else
{
matches = await _unitOfWork.PowderCatalog.FindAsync(p =>
p.Sku.ToLower() == term ||
p.ColorName.ToLower().Contains(term) ||
p.Sku.ToLower().Contains(term));
}
// Single query — all partial color/SKU matches across all vendors.
// Results are ranked: exact vendor + exact color (isExact=true) sorts first and
// triggers auto-fill in the JS. Everything else goes to the picker modal.
// This means a user who typed "Columbia Coatings" + "Lime Green" gets auto-fill
// only when that exact product is in the catalog; otherwise they see a ranked modal
// with same-vendor results at the top and a "Not Listed — Search Online" escape hatch.
var matches = await _unitOfWork.PowderCatalog.FindAsync(p =>
p.ColorName.ToLower().Contains(term) ||
p.Sku.ToLower() == term ||
p.Sku.ToLower().Contains(term));
var results = matches
.Where(p => !existingSkus.Contains(p.Sku.ToLower()))
.OrderBy(p => p.Sku.ToLower() == term ? 0 : 1)
.ThenBy(p => p.ColorName)
.Select(p => new
.Select(p =>
{
id = p.Id,
vendorName = p.VendorName,
sku = p.Sku,
colorName = p.ColorName,
description = p.Description,
unitPrice = p.UnitPrice,
imageUrl = p.ImageUrl,
sdsUrl = p.SdsUrl,
tdsUrl = p.TdsUrl,
applicationGuideUrl = p.ApplicationGuideUrl,
productUrl = p.ProductUrl,
isDiscontinued = p.IsDiscontinued,
cureTemperatureF = p.CureTemperatureF,
cureTimeMinutes = p.CureTimeMinutes,
finish = p.Finish,
colorFamilies = p.ColorFamilies,
requiresClearCoat = p.RequiresClearCoat,
coverageSqFtPerLb = p.CoverageSqFtPerLb,
specificGravity = p.SpecificGravity,
transferEfficiency = GetEffectiveTransferEfficiency(p.TransferEfficiency)
var vendorMatch = string.IsNullOrEmpty(vendorTerm) || p.VendorName.ToLower().Contains(vendorTerm);
var colorExact = p.ColorName.ToLower() == term;
return (p, isExact: vendorMatch && colorExact, vendorMatch, colorExact);
})
.OrderBy(x => x.isExact ? 0 : x.vendorMatch ? 1 : x.colorExact ? 2 : 3)
.ThenBy(x => x.p.ColorName)
.Select(x => new
{
id = x.p.Id,
vendorName = x.p.VendorName,
sku = x.p.Sku,
colorName = x.p.ColorName,
description = x.p.Description,
unitPrice = x.p.UnitPrice,
imageUrl = x.p.ImageUrl,
sdsUrl = x.p.SdsUrl,
tdsUrl = x.p.TdsUrl,
applicationGuideUrl = x.p.ApplicationGuideUrl,
productUrl = x.p.ProductUrl,
isDiscontinued = x.p.IsDiscontinued,
isExact = x.isExact,
cureTemperatureF = x.p.CureTemperatureF,
cureTimeMinutes = x.p.CureTimeMinutes,
finish = x.p.Finish,
colorFamilies = x.p.ColorFamilies,
requiresClearCoat = x.p.RequiresClearCoat,
coverageSqFtPerLb = x.p.CoverageSqFtPerLb,
specificGravity = x.p.SpecificGravity,
transferEfficiency = GetEffectiveTransferEfficiency(x.p.TransferEfficiency)
})
.ToList();
@@ -2,6 +2,7 @@ using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.DTOs.Kiosk;
using PowderCoating.Application.Interfaces;
@@ -39,6 +40,9 @@ public class KioskController : Controller
private readonly IHubContext<KioskHub> _kioskHub;
private readonly ILogger<KioskController> _logger;
private readonly ICompanyLogoService _logoService;
private readonly IMemoryCache _cache;
private static string SmsConsentCacheKey(int companyId) => $"kiosk-sms-consent:{companyId}";
/// <summary>Initialises all dependencies for the kiosk controller.</summary>
public KioskController(
@@ -49,7 +53,8 @@ public class KioskController : Controller
IEmailService emailService,
IHubContext<KioskHub> kioskHub,
ILogger<KioskController> logger,
ICompanyLogoService logoService)
ICompanyLogoService logoService,
IMemoryCache cache)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
@@ -59,6 +64,7 @@ public class KioskController : Controller
_kioskHub = kioskHub;
_logger = logger;
_logoService = logoService;
_cache = cache;
}
// =========================================================================
@@ -104,6 +110,10 @@ public class KioskController : Controller
if (company == null || company.KioskActivationToken != cookie.Value.token)
return Json(new { hasSession = false });
// Check for a staff-pushed SMS consent request before checking for intake sessions.
if (_cache.TryGetValue(SmsConsentCacheKey(cookie.Value.companyId), out (int customerId, string customerName) pending))
return Json(new { hasSession = false, smsConsentPending = true, customerId = pending.customerId, customerName = pending.customerName });
var window = DateTime.UtcNow.AddSeconds(-60);
var session = await _unitOfWork.KioskSessions.FirstOrDefaultAsync(
s => s.CompanyId == cookie.Value.companyId
@@ -116,6 +126,116 @@ public class KioskController : Controller
return Json(new { hasSession = true, sessionToken = session.SessionToken });
}
// =========================================================================
// SMS CONSENT (staff pushes to kiosk; customer agrees on tablet)
// =========================================================================
/// <summary>
/// Staff calls this (authenticated) from the Customer Details page to push an SMS
/// consent request to the front-desk kiosk tablet. Stores the customer ID in
/// IMemoryCache under a company-scoped key; the kiosk's PollSession endpoint picks
/// it up and returns smsConsentPending so the tablet can navigate to the consent page.
/// The cache entry expires in 10 minutes in case the customer never approaches the tablet.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> PushSmsConsent(int customerId)
{
var customer = await _unitOfWork.Customers.GetByIdAsync(customerId);
if (customer == null) return Json(new { success = false, message = "Customer not found." });
if (customer.NotifyBySms)
return Json(new { success = false, message = "Customer has already given SMS consent." });
var companyId = customer.CompanyId;
var name = !string.IsNullOrWhiteSpace(customer.ContactFirstName)
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
: customer.CompanyName ?? "Customer";
_cache.Set(SmsConsentCacheKey(companyId), (customerId, name),
new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) });
_logger.LogInformation("SMS consent pushed to kiosk for customer {CustomerId} by staff", customerId);
return Json(new { success = true });
}
/// <summary>
/// Cancels a pending kiosk SMS consent request, freeing the kiosk to return to the Welcome
/// screen. Called by staff if they pushed consent accidentally or the customer isn't coming.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public IActionResult CancelSmsConsent()
{
var companyId = HttpContext.User.FindFirst("CompanyId")?.Value;
if (int.TryParse(companyId, out var cid))
_cache.Remove(SmsConsentCacheKey(cid));
return Json(new { success = true });
}
/// <summary>
/// Displays the full-screen SMS consent form on the kiosk tablet (anonymous, kiosk layout).
/// Loads the customer by ID with ignoreQueryFilters because the kiosk has no tenant context.
/// </summary>
[AllowAnonymous]
public async Task<IActionResult> SmsConsent(int id)
{
var cookie = ReadKioskCookie();
if (cookie == null) return Forbid();
// Clear the pending entry immediately — the kiosk is now showing the form,
// so Welcome must not redirect again if the customer cancels or navigates back.
_cache.Remove(SmsConsentCacheKey(cookie.Value.companyId));
var customer = await _unitOfWork.Customers.GetByIdAsync(id, ignoreQueryFilters: true);
if (customer == null) return NotFound();
var company = await _unitOfWork.Companies.GetByIdAsync(cookie.Value.companyId, ignoreQueryFilters: true);
ViewBag.CompanyName = company?.CompanyName;
ViewBag.CompanyLogoUrl = !string.IsNullOrEmpty(company?.LogoFilePath) ? Url.Action("Logo", "Kiosk") : null;
ViewBag.ShowInactivityTimer = false;
ViewBag.CustomerName = !string.IsNullOrWhiteSpace(customer.ContactFirstName)
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
: customer.CompanyName ?? "Customer";
return View(id);
}
/// <summary>
/// Records the customer's SMS consent from the kiosk tablet.
/// Sets NotifyBySms, SmsConsentedAt, SmsConsentMethod = "KioskInPerson" on the customer record.
/// Cache is already cleared by the GET; this handles the agree/decline outcome.
/// </summary>
[AllowAnonymous, HttpPost]
public async Task<IActionResult> SmsConsent(int id, bool agreed)
{
var cookie = ReadKioskCookie();
if (cookie == null) return Forbid();
if (agreed)
{
var customer = await _unitOfWork.Customers.GetByIdAsync(id, ignoreQueryFilters: true);
if (customer != null)
{
customer.NotifyBySms = true;
customer.SmsConsentedAt = DateTime.UtcNow;
customer.SmsConsentMethod = "KioskInPerson";
customer.SmsOptedOutAt = null;
await _unitOfWork.Customers.UpdateAsync(customer);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("SMS consent recorded via kiosk for customer {CustomerId}", id);
await _inApp.CreateAsync(
customer.CompanyId,
"SMS Consent Recorded",
$"{customer.ContactFirstName} {customer.ContactLastName} agreed to SMS notifications on the kiosk.",
"KioskConsent",
link: $"/Customers/Details/{id}",
customerId: id);
}
}
return Redirect("/Kiosk/Welcome");
}
/// <summary>
/// Serves the company logo for anonymous kiosk pages. Resolves the company from the
/// KioskDevice cookie so no tenant context is needed on the anonymous request.
@@ -483,6 +603,7 @@ public class KioskController : Controller
ExpiresAt = s.ExpiresAt,
LinkedCustomerId = s.LinkedCustomerId,
LinkedJobId = s.LinkedJobId,
LinkedQuoteId = s.LinkedQuoteId,
RemoteLinkEmail = s.RemoteLinkEmail
})
.ToList();
@@ -590,55 +711,117 @@ public class KioskController : Controller
: "RemoteIntake";
}
// 3. Create Job in Pending status with Normal priority
var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
var pendingStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Pending);
if (pendingStatus == null)
throw new InvalidOperationException($"No Pending job status found for company {companyId}. Run Seed Data from Platform Management.");
// 3. Resolve company preference: create a Quote (default) or a Job
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true);
var intakeOutput = prefs?.KioskIntakeOutput ?? "Quote";
var createQuote = !string.Equals(intakeOutput, "Job", StringComparison.OrdinalIgnoreCase);
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL")
?? priorities.FirstOrDefault();
if (normalPriority == null)
throw new InvalidOperationException($"No job priority rows found for company {companyId}. Run Seed Data from Platform Management.");
session.LinkedCustomerId = customer!.Id;
var jobNumber = await GenerateJobNumberAsync(companyId);
var job = new Job
if (createQuote)
{
CompanyId = companyId,
CustomerId = customer!.Id,
JobNumber = jobNumber,
JobStatusId = pendingStatus.Id,
JobPriorityId = normalPriority.Id,
Description = session.JobDescription,
SpecialInstructions = $"Source: {session.SessionType} kiosk intake"
};
// 3a. Create a Draft Quote so staff can price and send for approval
var quoteStatuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId);
var draftStatus = quoteStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Draft);
if (draftStatus == null)
throw new InvalidOperationException($"No Draft quote status found for company {companyId}. Run Seed Data from Platform Management.");
await _unitOfWork.Jobs.AddAsync(job);
var quoteNumber = await GenerateQuoteNumberAsync(companyId);
var quote = new Quote
{
CompanyId = companyId,
CustomerId = customer.Id,
QuoteNumber = quoteNumber,
QuoteStatusId = draftStatus.Id,
Description = session.JobDescription,
Notes = $"Source: {session.SessionType} kiosk intake",
QuoteDate = DateTime.UtcNow,
ExpirationDate = DateTime.UtcNow.AddDays(prefs?.DefaultQuoteValidityDays ?? 30)
};
// 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
await _unitOfWork.Quotes.AddAsync(quote);
await _unitOfWork.CompleteAsync(); // quote.Id now valid
// 4. Update session links now that both Ids exist
session.LinkedCustomerId = customer.Id;
session.LinkedJobId = job.Id;
session.LinkedQuoteId = quote.Id;
}
else
{
// 3b. Create a Pending Job directly (for shops that price on the spot)
var jobStatuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
var pendingStatus = jobStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Pending);
if (pendingStatus == null)
throw new InvalidOperationException($"No Pending job status found for company {companyId}. Run Seed Data from Platform Management.");
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL")
?? priorities.FirstOrDefault();
if (normalPriority == null)
throw new InvalidOperationException($"No job priority rows found for company {companyId}. Run Seed Data from Platform Management.");
var jobNumber = await GenerateJobNumberAsync(companyId);
var job = new Job
{
CompanyId = companyId,
CustomerId = customer.Id,
JobNumber = jobNumber,
JobStatusId = pendingStatus.Id,
JobPriorityId = normalPriority.Id,
Description = session.JobDescription,
SpecialInstructions = $"Source: {session.SessionType} kiosk intake"
};
await _unitOfWork.Jobs.AddAsync(job);
await _unitOfWork.CompleteAsync(); // job.Id now valid
session.LinkedJobId = job.Id;
}
// 4. Persist session links
await _unitOfWork.CompleteAsync();
// 5. Fire staff notification
var jobDesc = session.JobDescription ?? "";
var snippet = jobDesc.Length > 60 ? jobDesc[..60] + "…" : jobDesc;
var fullName = $"{session.CustomerFirstName} {session.CustomerLastName}".Trim();
var jobDesc = session.JobDescription ?? "";
var snippet = jobDesc.Length > 60 ? jobDesc[..60] + "…" : jobDesc;
var fullName = $"{session.CustomerFirstName} {session.CustomerLastName}".Trim();
var intakeLabel = session.SessionType == KioskSessionType.Remote ? "Remote Intake" : "Walk-in Intake";
await _inApp.CreateAsync(
companyId,
"Walk-in Intake Submitted",
$"{intakeLabel} Submitted",
$"{fullName} completed their intake form — {snippet}",
"KioskIntake",
link: $"/Kiosk/Intakes",
customerId: customer.Id);
}
/// <summary>
/// Generates the next sequential quote number using the company's configured prefix.
/// Mirrors GenerateQuoteNumberAsync in QuotesController — same format: PREFIX-YYMM-####.
/// Implemented here because KioskController processes anonymous requests and cannot
/// rely on ITenantContext to resolve the company ID.
/// </summary>
private async Task<string> GenerateQuoteNumberAsync(int companyId)
{
var now = DateTime.UtcNow;
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true);
var quotePrefix = !string.IsNullOrWhiteSpace(prefs?.QuoteNumberPrefix) ? prefs.QuoteNumberPrefix : "QT";
var prefix = $"{quotePrefix}-{now:yy}{now:MM}";
var lastQuoteNumber = await _unitOfWork.Quotes.GetLastQuoteNumberByPrefixAsync(companyId, prefix);
if (lastQuoteNumber != null)
{
var lastNumberStr = lastQuoteNumber[(prefix.Length + 1)..];
if (int.TryParse(lastNumberStr, out int lastNumber))
return $"{prefix}-{(lastNumber + 1):D4}";
}
return $"{prefix}-0001";
}
/// <summary>
/// Generates the next sequential job number using the company's configured prefix.
/// Mirrors the logic in JobsController.GenerateJobNumber() — same format: PREFIX-YYMM-####.
@@ -716,7 +899,11 @@ public class KioskController : Controller
? Url.Action("Logo", "Kiosk")
: null;
ViewBag.WelcomeUrl = "/Kiosk/Welcome";
await Task.CompletedTask;
// Pass the intake output setting so Terms.cshtml can show matching wording
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
p => p.CompanyId == company.Id && !p.IsDeleted, ignoreQueryFilters: true);
ViewBag.KioskIntakeOutput = prefs?.KioskIntakeOutput ?? "Quote";
}
/// <summary>Loads the company from a session's CompanyId and populates ViewBag.</summary>
@@ -1270,7 +1270,11 @@ public static class HelpKnowledgeBase
**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):**
1. Go to Settings Kiosk Setup (or /Kiosk/Activate)
@@ -1293,23 +1297,24 @@ public static class HelpKnowledgeBase
**What happens on submission:**
- 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
- 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):**
- Filter tabs: All / Submitted / Pending / Expired
- 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
- 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.
**Troubleshooting:**
- "Connection issue — retrying…" on tablet: Wi-Fi problem; dot auto-recovers when connectivity returns
- Tablet doesn't respond to Start Intake: waits up to 3 s; reload Welcome page if still stuck
- No View 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
- 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:**
Create Quote for prospect Quote Approved Convert Prospect to Customer Convert Quote to Job
**Walk-in customer intake (kiosk):**
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
**Walk-in customer intake (kiosk Quote mode):**
Staff clicks "Start Intake" on Dashboard tablet navigates to intake form within 3 s customer fills out 3 steps (contact, job description, terms + signature) system creates Customer + Draft Quote "Walk-in Intake Submitted" notification fires staff reviews at /Kiosk/Intakes clicks "View Quote" to price and send the quote
**Walk-in customer intake (kiosk Job mode):**
Same flow as above, but system creates a Pending Job instead of a Quote staff clicks "View Job" to assign a worker and progress the job through the workflow
**Remote intake (customer fills out before arriving):**
Staff clicks "Send Intake Link" on Dashboard or Intakes page enters customer email customer receives link and completes form on their own device same auto-create flow as in-person
Staff clicks "Send Intake Link" on Dashboard or Intakes page enters customer email customer receives link and completes form on their own device same auto-create flow as in-person; notification reads "Remote Intake Submitted"
**Walk-in / phone quote (quick estimate):**
Click the AI Quick Quote button (dark-blue floating button, bottom-right) type description AI returns price estimate Save as draft under "Walk-In / Phone" open the quote reassign the Customer dropdown on Quote Details to the real customer record once you have their info
@@ -72,6 +72,7 @@ public class InAppNotificationService : IInAppNotificationService
message = notification.Message,
link = notification.Link,
notificationType = notification.NotificationType,
customerId = notification.CustomerId,
createdAt = now.ToString("o")
});
}
@@ -26,11 +26,13 @@
var totalPages = (int)(ViewBag.TotalPages ?? 1);
var totalCount = (int)(ViewBag.TotalCount ?? 0);
var impersonatingId = (int?)(ViewBag.ImpersonatingCompanyId);
var showChurned = (bool)(ViewBag.ShowChurned ?? false);
var churnedCount = (int)(ViewBag.ChurnedCount ?? 0);
string SortLink(string col)
{
var dir = (sortColumn == col && sortDirection == "asc") ? "desc" : "asc";
return Url.Action("Index", new { searchTerm, sortColumn = col, sortDirection = dir, pageNumber = 1, pageSize })!;
return Url.Action("Index", new { searchTerm, sortColumn = col, sortDirection = dir, pageNumber = 1, pageSize, showChurned })!;
}
string SortIcon(string col)
@@ -54,6 +56,7 @@
<input type="hidden" name="sortColumn" value="@sortColumn" />
<input type="hidden" name="sortDirection" value="@sortDirection" />
<input type="hidden" name="pageSize" value="@pageSize" />
<input type="hidden" name="showChurned" value="@showChurned.ToString().ToLower()" />
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text"><i class="bi bi-search"></i></span>
@@ -75,6 +78,25 @@
</div>
</div>
@if (churnedCount > 0 && !showChurned)
{
<div class="alert alert-secondary alert-permanent d-flex align-items-center gap-2 mb-3 py-2">
<i class="bi bi-eye-slash text-muted"></i>
<span class="small"><strong>@churnedCount</strong> churned @(churnedCount == 1 ? "account" : "accounts") (expired or canceled 14+ days ago) hidden.</span>
<a href="@Url.Action("Index", new { searchTerm, sortColumn, sortDirection, pageNumber = 1, pageSize, showChurned = true })"
class="btn btn-sm btn-outline-secondary ms-auto py-0">Show churned</a>
</div>
}
else if (showChurned && churnedCount > 0)
{
<div class="alert alert-warning alert-permanent d-flex align-items-center gap-2 mb-3 py-2">
<i class="bi bi-eye text-warning"></i>
<span class="small">Showing all accounts including <strong>@churnedCount</strong> churned.</span>
<a href="@Url.Action("Index", new { searchTerm, sortColumn, sortDirection, pageNumber = 1, pageSize, showChurned = false })"
class="btn btn-sm btn-outline-secondary ms-auto py-0">Hide churned</a>
</div>
}
<div class="card shadow-sm">
<div class="card-body p-0">
@if (Model != null && Model.Any())
@@ -313,18 +335,18 @@
<nav>
<ul class="pagination pagination-sm mb-0">
<li class="page-item @(pageNumber == 1 ? "disabled" : "")">
<a class="page-link" href="@Url.Action("Index", new { searchTerm, sortColumn, sortDirection, pageNumber = pageNumber - 1, pageSize })">
<a class="page-link" href="@Url.Action("Index", new { searchTerm, sortColumn, sortDirection, pageNumber = pageNumber - 1, pageSize, showChurned })">
<i class="bi bi-chevron-left"></i>
</a>
</li>
@for (int p = Math.Max(1, pageNumber - 2); p <= Math.Min(totalPages, pageNumber + 2); p++)
{
<li class="page-item @(p == pageNumber ? "active" : "")">
<a class="page-link" href="@Url.Action("Index", new { searchTerm, sortColumn, sortDirection, pageNumber = p, pageSize })">@p</a>
<a class="page-link" href="@Url.Action("Index", new { searchTerm, sortColumn, sortDirection, pageNumber = p, pageSize, showChurned })">@p</a>
</li>
}
<li class="page-item @(pageNumber == totalPages ? "disabled" : "")">
<a class="page-link" href="@Url.Action("Index", new { searchTerm, sortColumn, sortDirection, pageNumber = pageNumber + 1, pageSize })">
<a class="page-link" href="@Url.Action("Index", new { searchTerm, sortColumn, sortDirection, pageNumber = pageNumber + 1, pageSize, showChurned })">
<i class="bi bi-chevron-right"></i>
</a>
</li>
@@ -464,6 +486,7 @@
const url = new URL(window.location.href);
url.searchParams.set('pageSize', size);
url.searchParams.set('pageNumber', '1');
url.searchParams.set('showChurned', '@showChurned.ToString().ToLower()');
window.location.href = url.toString();
}
@@ -4,6 +4,9 @@
@{
ViewData["Title"] = "Company Health";
var showChurned = (bool)(ViewBag.ShowChurned ?? false);
var churnedCount = (int)(ViewBag.ChurnedCount ?? 0);
string RiskBadge(ChurnRisk r) => r switch {
ChurnRisk.Healthy => "bg-success",
ChurnRisk.AtRisk => "bg-warning text-dark",
@@ -73,6 +76,26 @@
</div>
</div>
@* Churned account visibility banner *@
@if (churnedCount > 0 && !showChurned)
{
<div class="alert alert-secondary alert-permanent d-flex align-items-center gap-2 mb-3 py-2">
<i class="bi bi-eye-slash text-muted"></i>
<span class="small"><strong>@churnedCount</strong> churned @(churnedCount == 1 ? "account" : "accounts") (expired or canceled 14+ days ago) hidden from scores and totals.</span>
<a href="@Url.Action("Index", new { risk = ViewBag.Risk, search = ViewBag.Search, configIssuesOnly = ViewBag.ConfigIssuesOnly, showChurned = true })"
class="btn btn-sm btn-outline-secondary ms-auto py-0">Show churned</a>
</div>
}
else if (showChurned && churnedCount > 0)
{
<div class="alert alert-warning alert-permanent d-flex align-items-center gap-2 mb-3 py-2">
<i class="bi bi-eye text-warning"></i>
<span class="small">Showing all accounts including <strong>@churnedCount</strong> churned.</span>
<a href="@Url.Action("Index", new { risk = ViewBag.Risk, search = ViewBag.Search, configIssuesOnly = ViewBag.ConfigIssuesOnly, showChurned = false })"
class="btn btn-sm btn-outline-secondary ms-auto py-0">Hide churned</a>
</div>
}
@* Summary stat cards *@
<div class="row g-3 mb-3">
<div class="col-6 col-lg-3">
@@ -193,6 +216,7 @@
<label class="form-check-label small" for="configOnly">Config issues only</label>
</div>
</div>
<input type="hidden" name="showChurned" value="@showChurned.ToString().ToLower()" />
<div class="col-auto">
<button class="btn btn-sm btn-primary">Filter</button>
<a asp-action="Index" class="btn btn-sm btn-outline-secondary ms-1">Clear</a>
@@ -34,6 +34,7 @@
<option value="data-retention">Data Retention</option>
<option value="data-lookups">Data Lookups</option>
<option value="pdf-templates">PDF Templates</option>
<option value="kiosk">Kiosk</option>
</select>
</div>
@@ -100,6 +101,11 @@
</button>
</li>
}
<li class="nav-item" role="presentation">
<button class="nav-link" id="kiosk-tab" data-bs-toggle="tab" data-bs-target="#kiosk" type="button" role="tab">
<i class="bi bi-tablet"></i> Kiosk
</button>
</li>
</ul>
<!-- Tabs Content -->
@@ -1978,6 +1984,67 @@
</div>
</div>
}
<!-- Kiosk Tab -->
<div class="tab-pane fade" id="kiosk" role="tabpanel">
<div class="card mt-3">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-tablet me-2"></i>Customer Intake Kiosk</h5>
</div>
<div class="card-body">
<h6 class="fw-semibold mb-1">Intake Output</h6>
<p class="text-muted small mb-3">
When a customer completes the intake form, what should be created in the system?
</p>
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="card h-100 border @(Model.Preferences?.KioskIntakeOutput == "Job" ? "" : "border-primary bg-primary-subtle")"
id="kioskOutputQuoteCard" style="cursor:pointer;" onclick="selectKioskOutput('Quote')">
<div class="card-body">
<div class="d-flex align-items-center gap-2 mb-2">
<div class="form-check mb-0">
<input class="form-check-input" type="radio" name="kioskOutput" id="kioskOutputQuote"
value="Quote" @(Model.Preferences?.KioskIntakeOutput != "Job" ? "checked" : "") />
</div>
<h6 class="mb-0 fw-semibold"><i class="bi bi-file-earmark-text me-1 text-primary"></i>Create a Quote</h6>
</div>
<p class="text-muted small mb-0">
A draft quote is created and reviewed by staff before work begins.
Best for shops that price after seeing the parts.
</p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card h-100 border @(Model.Preferences?.KioskIntakeOutput == "Job" ? "border-success bg-success-subtle" : "")"
id="kioskOutputJobCard" style="cursor:pointer;" onclick="selectKioskOutput('Job')">
<div class="card-body">
<div class="d-flex align-items-center gap-2 mb-2">
<div class="form-check mb-0">
<input class="form-check-input" type="radio" name="kioskOutput" id="kioskOutputJob"
value="Job" @(Model.Preferences?.KioskIntakeOutput == "Job" ? "checked" : "") />
</div>
<h6 class="mb-0 fw-semibold"><i class="bi bi-briefcase me-1 text-success"></i>Create a Job</h6>
</div>
<p class="text-muted small mb-0">
A job is created immediately on submission.
Best for shops that price on the spot and want the work order ready right away.
</p>
</div>
</div>
</div>
</div>
<button type="button" class="btn btn-primary" onclick="saveKioskSettings()">
<i class="bi bi-floppy me-1"></i> Save Kiosk Settings
</button>
</div>
</div>
</div>
</div>
</div>
@@ -3248,12 +3315,41 @@
else showError(data.message);
}
function selectKioskOutput(value) {
document.getElementById('kioskOutputQuote').checked = value === 'Quote';
document.getElementById('kioskOutputJob').checked = value === 'Job';
document.getElementById('kioskOutputQuoteCard').classList.toggle('border-primary', value === 'Quote');
document.getElementById('kioskOutputQuoteCard').classList.toggle('bg-primary-subtle', value === 'Quote');
document.getElementById('kioskOutputJobCard').classList.toggle('border-success', value === 'Job');
document.getElementById('kioskOutputJobCard').classList.toggle('bg-success-subtle', value === 'Job');
}
async function saveKioskSettings() {
const value = document.querySelector('input[name="kioskOutput"]:checked')?.value ?? 'Quote';
const resp = await fetch('/CompanySettings/UpdateKioskSettings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').val()
},
body: JSON.stringify({ kioskIntakeOutput: value })
});
const data = await resp.json();
if (data.success) showSuccess(data.message);
else showError(data.message);
}
// Auto-open online-payments tab if redirected with ?tab=online-payments
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('tab') === 'online-payments') {
const btn = document.querySelector('[data-bs-target="#online-payments"]');
if (btn) new bootstrap.Tab(btn).show();
}
if (urlParams.get('tab') === 'kiosk') {
const btn = document.querySelector('[data-bs-target="#kiosk"]');
if (btn) new bootstrap.Tab(btn).show();
}
</script>
}
@@ -173,9 +173,11 @@
<i class="bi bi-envelope-slash me-1"></i>Email off
</span>
}
<span id="sms-status-section">
@if (Model.NotifyBySms)
{
<span class="badge bg-success bg-opacity-10 text-success border border-success border-opacity-25">
<span class="badge bg-success bg-opacity-10 text-success border border-success border-opacity-25"
title="@(Model.SmsConsentedAt.HasValue ? "Consented " + Model.SmsConsentedAt.Value.ToLocalTime().ToString("MM/dd/yyyy") : "")">
<i class="bi bi-chat-fill me-1"></i>SMS on
</span>
}
@@ -184,7 +186,22 @@
<span class="badge bg-secondary bg-opacity-10 text-secondary border border-secondary border-opacity-25">
<i class="bi bi-chat-slash me-1"></i>SMS off
</span>
<button type="button" id="btnGetSmsConsent"
class="badge bg-primary bg-opacity-10 text-primary border border-primary border-opacity-25 border-0"
style="cursor:pointer;"
title="Send SMS consent form to the front-desk kiosk tablet"
onclick="pushSmsConsent(@Model.Id)">
<i class="bi bi-chat-dots me-1"></i>Get SMS Consent
</button>
<button type="button" id="btnCancelSmsConsent"
class="badge bg-warning bg-opacity-10 text-warning border border-warning border-opacity-25 border-0 d-none"
style="cursor:pointer;"
title="Cancel the pending kiosk consent request"
onclick="cancelSmsConsent()">
<i class="bi bi-x-circle me-1"></i>Cancel Consent
</button>
}
</span>
</div>
</div>
</div>
@@ -543,3 +560,8 @@
</div>
</div>
}
@section Scripts {
<script src="~/js/customer-details.js" asp-append-version="true"></script>
}
@@ -21,9 +21,9 @@
</h2>
<p>
The Customer Intake Kiosk lets walk-in customers fill out their own intake form on a front-desk tablet
— no staff assistance required. When they're done, a <strong>Pending job</strong> and a
<strong>customer record</strong> are automatically created, and your team receives an in-app
notification so they know someone is waiting.
— no staff assistance required. When they're done, a <strong>customer record</strong> is automatically
created (or matched to an existing one), a <strong>Draft Quote or Pending Job</strong> is created
depending on your setting, and your team receives an in-app notification.
</p>
<p>
The kiosk runs as a browser page (optimised for iPad and Android tablets) and can also send a
@@ -89,6 +89,28 @@
</ol>
</section>
<section id="output-setting" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-sliders text-primary me-2"></i>Kiosk Output Setting
</h2>
<p>
You can control what gets created when a customer submits the intake form.
Go to <a href="/CompanySettings?tab=kiosk">Company Settings → Kiosk</a> and choose:
</p>
<ul>
<li>
<strong>Create a Quote</strong> (default) — a Draft quote is created for staff to review and price
before work begins. The terms shown to the customer will say "subject to a formal quote." Use this
if you price after seeing the parts.
</li>
<li>
<strong>Create a Job</strong> — a Pending job is created immediately. The terms will say "a team
member will reach out about pricing." Use this if you price on the spot and want the work order
ready right away.
</li>
</ul>
</section>
<section id="what-happens" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-arrow-right-circle text-primary me-2"></i>What Happens on Submission
@@ -96,9 +118,18 @@
<p>When a customer submits their intake form, the system automatically:</p>
<ul>
<li><strong>Matches or creates a Customer</strong> — searches by email first, then phone. If no match, a new non-commercial customer record is created.</li>
<li><strong>Creates a 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>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>
</section>
@@ -116,14 +147,15 @@
<li>Job description snippet</li>
<li>Session type (In-Person or Remote) and status badge</li>
<li>SMS opt-in indicator</li>
<li><strong>View 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>
</ul>
<div class="alert alert-info alert-permanent">
<i class="bi bi-info-circle me-2"></i>
If submission failed (e.g. a configuration issue), the session is still marked Submitted but the
View Job / Customer buttons won't appear. The raw intake data (name, phone, description) is still
visible so staff can create the job manually.
action buttons won't appear. The raw intake data (name, phone, description) is still
visible so staff can create the record manually.
</div>
</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="#setup">Setting Up the Kiosk</a>
<a class="nav-link py-1 px-3" href="#starting">Starting an Intake</a>
<a class="nav-link py-1 px-3" href="#output-setting">Kiosk Output Setting</a>
<a class="nav-link py-1 px-3" href="#what-happens">What Happens on Submission</a>
<a class="nav-link py-1 px-3" href="#reviewing">Reviewing Submissions</a>
<a class="nav-link py-1 px-3" href="#troubleshooting">Troubleshooting</a>
@@ -2,8 +2,9 @@
@{
Layout = "~/Views/Shared/_KioskLayout.cshtml";
ViewData["Title"] = "Terms & Consent";
var token = ViewBag.SessionToken as Guid? ?? Guid.Empty;
bool isInPerson = ViewBag.IsInPerson as bool? ?? false;
var token = ViewBag.SessionToken as Guid? ?? Guid.Empty;
bool isInPerson = ViewBag.IsInPerson as bool? ?? false;
bool quoteFirst = !string.Equals(ViewBag.KioskIntakeOutput as string, "Job", StringComparison.OrdinalIgnoreCase);
}
<div class="kiosk-card">
@@ -25,10 +26,20 @@
have authority to authorize work on them. You release the shop from liability for
pre-existing damage, hidden defects, or items left unclaimed after 30 days.
</p>
<p>
Final pricing is subject to a formal quote. Work will not begin until you approve
the quoted amount. Payment is due upon pickup unless otherwise agreed in writing.
</p>
@if (quoteFirst)
{
<p>
Final pricing is subject to a formal quote. Work will not begin until you approve
the quoted amount. Payment is due upon pickup unless otherwise agreed in writing.
</p>
}
else
{
<p>
A team member will review your intake and reach out about pricing before work begins.
Payment is due upon pickup unless otherwise agreed in writing.
</p>
}
<p class="mb-0">
You agree to comply with all pickup and payment terms provided by the shop.
</p>
@@ -146,6 +146,12 @@
<i class="bi bi-briefcase me-1"></i>View Job
</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)
{
<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 &amp; 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 = {
QuoteApproved: { icon: 'bi-check-circle-fill', cls: 'success', title: 'Quote Approved' },
QuoteDeclined: { icon: 'bi-x-circle-fill', cls: 'danger', title: 'Quote Declined' },
InvoicePaid: { icon: 'bi-cash-coin', cls: 'primary', title: 'Payment Received' }
InvoicePaid: { icon: 'bi-cash-coin', cls: 'primary', title: 'Payment Received' },
KioskConsent: { icon: 'bi-chat-fill', cls: 'success', title: 'SMS Consent' }
};
const t = icons[data.notificationType] || { icon: 'bi-bell', cls: 'info', title: 'Notification' };
toastr[t.cls === 'danger' ? 'warning' : t.cls === 'primary' ? 'info' : 'success'](
@@ -1922,6 +1923,12 @@
`<i class="bi ${t.icon} me-1"></i>${t.title}`,
{ timeOut: 10000, extendedTimeOut: 3000, closeButton: true, enableHtml: true }
);
if (data.notificationType === 'KioskConsent' && data.customerId) {
const path = window.location.pathname.toLowerCase();
if (path === `/customers/details/${data.customerId}`) {
window.updateCustomerSmsStatus?.();
}
}
});
connection.start().catch(err => console.warn('SignalR connection failed:', err));
@@ -2101,8 +2108,14 @@
});
});
// Load on page ready
document.addEventListener('DOMContentLoaded', load);
// Load on page ready and refresh when dropdown is opened
document.addEventListener('DOMContentLoaded', () => {
load();
btn?.addEventListener('show.bs.dropdown', load);
});
// Fallback poll every 60 s in case SignalR misses a push
setInterval(load, 60_000);
return { addItem, incrementBadge, markAllRead, openDetail, markRead };
})();
@@ -60,6 +60,14 @@ body.kiosk-body {
width: 100%;
}
/* Vertically centre content in any tall kiosk button (covers <a> and <button>) */
.kiosk-body .btn,
.kiosk-btn {
display: inline-flex;
align-items: center;
justify-content: center;
}
/* Suppress all hover effects on touch screens */
@media (hover: none) {
.kiosk-body .btn:hover { filter: none; opacity: 1; }
@@ -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();
if (items.length === 0) {
// No catalog match — fall back to AI if available
hideStatus();
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.');
}
// Nothing in catalog — go straight to AI
await runAiOrWarn();
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]);
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();
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 ────────────────────────────────────
async function fillFields(item) {
@@ -368,6 +382,12 @@
<div class="modal-body p-0">
<div class="list-group list-group-flush">${rows}</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>`;
@@ -383,6 +403,11 @@
});
});
document.getElementById('catalogPickerNotListed').addEventListener('click', function () {
bsModal.hide();
runAiOrWarn();
});
bsModal.show();
}
@@ -26,6 +26,12 @@
if (!res.ok) throw new Error("HTTP " + res.status);
const data = await res.json();
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) {
active = false;
setStatus("#2563eb", "Starting…");