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:
@@ -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; }
|
||||||
|
|||||||
Generated
+10742
File diff suppressed because it is too large
Load Diff
+82
@@ -0,0 +1,82 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddKioskIntakeOutputSetting : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "LinkedQuoteId",
|
||||||
|
table: "KioskSessions",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "KioskIntakeOutput",
|
||||||
|
table: "CompanyPreferences",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2349));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2366));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2367));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LinkedQuoteId",
|
||||||
|
table: "KioskSessions");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "KioskIntakeOutput",
|
||||||
|
table: "CompanyPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4259));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4264));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4266));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2253,6 +2253,10 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<int>("JobRetentionYears")
|
b.Property<int>("JobRetentionYears")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("KioskIntakeOutput")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<int>("LogRetentionDays")
|
b.Property<int>("LogRetentionDays")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -5637,6 +5641,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<int?>("LinkedJobId")
|
b.Property<int?>("LinkedJobId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int?>("LinkedQuoteId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<string>("RemoteLinkEmail")
|
b.Property<string>("RemoteLinkEmail")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -6692,7 +6699,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4259),
|
CreatedAt = new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2349),
|
||||||
Description = "Standard pricing for regular customers",
|
Description = "Standard pricing for regular customers",
|
||||||
DiscountPercent = 0m,
|
DiscountPercent = 0m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6703,7 +6710,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4264),
|
CreatedAt = new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2366),
|
||||||
Description = "5% discount for preferred customers",
|
Description = "5% discount for preferred customers",
|
||||||
DiscountPercent = 5m,
|
DiscountPercent = 5m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6714,7 +6721,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4266),
|
CreatedAt = new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2367),
|
||||||
Description = "10% discount for premium customers",
|
Description = "10% discount for premium customers",
|
||||||
DiscountPercent = 10m,
|
DiscountPercent = 10m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
|
|||||||
@@ -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,55 +591,117 @@ 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);
|
||||||
if (pendingStatus == null)
|
var intakeOutput = prefs?.KioskIntakeOutput ?? "Quote";
|
||||||
throw new InvalidOperationException($"No Pending job status found for company {companyId}. Run Seed Data from Platform Management.");
|
var createQuote = !string.Equals(intakeOutput, "Job", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
|
session.LinkedCustomerId = customer!.Id;
|
||||||
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);
|
if (createQuote)
|
||||||
var job = new Job
|
|
||||||
{
|
{
|
||||||
CompanyId = companyId,
|
// 3a. Create a Draft Quote so staff can price and send for approval
|
||||||
CustomerId = customer!.Id,
|
var quoteStatuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId);
|
||||||
JobNumber = jobNumber,
|
var draftStatus = quoteStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Draft);
|
||||||
JobStatusId = pendingStatus.Id,
|
if (draftStatus == null)
|
||||||
JobPriorityId = normalPriority.Id,
|
throw new InvalidOperationException($"No Draft quote status found for company {companyId}. Run Seed Data from Platform Management.");
|
||||||
Description = session.JobDescription,
|
|
||||||
SpecialInstructions = $"Source: {session.SessionType} kiosk intake"
|
|
||||||
};
|
|
||||||
|
|
||||||
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.
|
await _unitOfWork.Quotes.AddAsync(quote);
|
||||||
// Setting session.LinkedJobId = job.Id before CompleteAsync would write 0
|
await _unitOfWork.CompleteAsync(); // quote.Id now valid
|
||||||
// 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.LinkedQuoteId = quote.Id;
|
||||||
session.LinkedCustomerId = customer.Id;
|
}
|
||||||
session.LinkedJobId = job.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();
|
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>
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
@{
|
@{
|
||||||
Layout = "~/Views/Shared/_KioskLayout.cshtml";
|
Layout = "~/Views/Shared/_KioskLayout.cshtml";
|
||||||
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>
|
||||||
<p>
|
@if (quoteFirst)
|
||||||
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>
|
||||||
</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">
|
<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">
|
||||||
|
|||||||
Reference in New Issue
Block a user