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>
This commit is contained in:
2026-05-13 22:35:37 -04:00
parent d134dd51e5
commit d5ad9fa073
13 changed files with 11087 additions and 43 deletions
@@ -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; }
@@ -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,
@@ -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 —
@@ -483,6 +483,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 +591,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 +643,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 +652,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 +779,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>
@@ -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>
} }
@@ -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">