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
@@ -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 —
@@ -483,6 +483,7 @@ public class KioskController : Controller
ExpiresAt = s.ExpiresAt,
LinkedCustomerId = s.LinkedCustomerId,
LinkedJobId = s.LinkedJobId,
LinkedQuoteId = s.LinkedQuoteId,
RemoteLinkEmail = s.RemoteLinkEmail
})
.ToList();
@@ -590,55 +591,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 +779,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>
@@ -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>
}
@@ -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">