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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user