Compare commits

..

3 Commits

Author SHA1 Message Date
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
18 changed files with 11265 additions and 61 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 —
@@ -877,6 +877,74 @@ public class CustomersController : Controller
} }
} }
/// <summary>
/// Displays a full-screen SMS consent form for the customer to read and agree to.
/// Staff opens this page on a tablet and hands it to the customer; no staff account
/// interaction is required — the page is scoped to the customer by ID only.
/// Redirects back to Details if the customer has already consented.
/// </summary>
// GET: Customers/SmsConsent/5
public async Task<IActionResult> SmsConsent(int id)
{
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
if (customer == null) return NotFound();
if (customer.NotifyBySms)
{
this.ToastInfo("This customer has already given SMS consent.");
return RedirectToAction(nameof(Details), new { id });
}
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId.HasValue)
{
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
ViewBag.CompanyName = company?.CompanyName;
ViewBag.CompanyLogoUrl = !string.IsNullOrEmpty(company?.LogoFilePath)
? Url.Action("Logo", "Kiosk")
: null;
}
ViewBag.ShowInactivityTimer = false;
ViewBag.CustomerName = $"{customer.ContactFirstName} {customer.ContactLastName}".Trim();
if (string.IsNullOrWhiteSpace(ViewBag.CustomerName as string) && !string.IsNullOrEmpty(customer.CompanyName))
ViewBag.CustomerName = customer.CompanyName;
return View(customer.Id);
}
/// <summary>
/// Records the customer's SMS consent: sets NotifyBySms, SmsConsentedAt (UTC now),
/// and SmsConsentMethod = "InPerson". Called when the customer taps "I Agree" on the
/// consent form presented by staff.
/// </summary>
// POST: Customers/SmsConsent/5
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> SmsConsent(int id, bool agreed)
{
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
if (customer == null) return NotFound();
if (!agreed)
{
this.ToastError("Customer did not agree to SMS consent.");
return RedirectToAction(nameof(Details), new { id });
}
customer.NotifyBySms = true;
customer.SmsConsentedAt = DateTime.UtcNow;
customer.SmsConsentMethod = "InPerson";
customer.SmsOptedOutAt = null;
await _unitOfWork.Customers.UpdateAsync(customer);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("SMS consent recorded for customer {CustomerId} via staff-presented form", id);
this.ToastSuccess($"SMS consent recorded for {customer.ContactFirstName} {customer.ContactLastName}.");
return RedirectToAction(nameof(Details), new { id });
}
/// <summary> /// <summary>
/// Issues a standalone credit memo and increments the customer's CreditBalance. /// Issues a standalone credit memo and increments the customer's CreditBalance.
/// Restricted to CompanyAdmin because credits affect the financial ledger. The memo /// Restricted to CompanyAdmin because credits affect the financial ledger. The memo
@@ -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>
@@ -1270,7 +1270,11 @@ public static class HelpKnowledgeBase
**Where:** Kiosk Setup [/Kiosk/Activate](/Kiosk/Activate) | Intake Sessions [/Kiosk/Intakes](/Kiosk/Intakes) **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):** **Setup (one-time per device):**
1. Go to Settings Kiosk Setup (or /Kiosk/Activate) 1. Go to Settings Kiosk Setup (or /Kiosk/Activate)
@@ -1293,23 +1297,24 @@ public static class HelpKnowledgeBase
**What happens on submission:** **What happens on submission:**
- Customer is matched by email (first), then phone; if no match, a new non-commercial customer is created - 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 - 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):** **Reviewing submissions (Intake Sessions page):**
- Filter tabs: All / Submitted / Pending / Expired - Filter tabs: All / Submitted / Pending / Expired
- Each row shows customer name, phone, email, job description snippet, session type badge, SMS opt-in icon - 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 - "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. **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:** **Troubleshooting:**
- "Connection issue — retrying…" on tablet: Wi-Fi problem; dot auto-recovers when connectivity returns - "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 - 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 - 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 - 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:** **Prospect to customer:**
Create Quote for prospect Quote Approved Convert Prospect to Customer Convert Quote to Job Create Quote for prospect Quote Approved Convert Prospect to Customer Convert Quote to Job
**Walk-in customer intake (kiosk):** **Walk-in customer intake (kiosk Quote mode):**
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 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):** **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):** **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 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
@@ -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>
} }
@@ -175,7 +175,8 @@
} }
@if (Model.NotifyBySms) @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 <i class="bi bi-chat-fill me-1"></i>SMS on
</span> </span>
} }
@@ -184,6 +185,11 @@
<span class="badge bg-secondary bg-opacity-10 text-secondary border border-secondary border-opacity-25"> <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 <i class="bi bi-chat-slash me-1"></i>SMS off
</span> </span>
<a href="/Customers/SmsConsent/@Model.Id"
class="badge bg-primary bg-opacity-10 text-primary border border-primary border-opacity-25 text-decoration-none"
title="Present SMS consent form to customer">
<i class="bi bi-chat-dots me-1"></i>Get SMS Consent
</a>
} }
</div> </div>
</div> </div>
@@ -0,0 +1,45 @@
@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="/Customers/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="/Customers/Details/@Model" 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>
</div>
@@ -21,9 +21,9 @@
</h2> </h2>
<p> <p>
The Customer Intake Kiosk lets walk-in customers fill out their own intake form on a front-desk tablet 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 — no staff assistance required. When they're done, a <strong>customer record</strong> is automatically
<strong>customer record</strong> are automatically created, and your team receives an in-app created (or matched to an existing one), a <strong>Draft Quote or Pending Job</strong> is created
notification so they know someone is waiting. depending on your setting, and your team receives an in-app notification.
</p> </p>
<p> <p>
The kiosk runs as a browser page (optimised for iPad and Android tablets) and can also send a The kiosk runs as a browser page (optimised for iPad and Android tablets) and can also send a
@@ -89,6 +89,28 @@
</ol> </ol>
</section> </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"> <section id="what-happens" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3"> <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 <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> <p>When a customer submits their intake form, the system automatically:</p>
<ul> <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>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>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> </ul>
</section> </section>
@@ -116,14 +147,15 @@
<li>Job description snippet</li> <li>Job description snippet</li>
<li>Session type (In-Person or Remote) and status badge</li> <li>Session type (In-Person or Remote) and status badge</li>
<li>SMS opt-in indicator</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> <li><strong>Customer</strong> button — opens the matched or created customer record</li>
</ul> </ul>
<div class="alert alert-info alert-permanent"> <div class="alert alert-info alert-permanent">
<i class="bi bi-info-circle me-2"></i> <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 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 action buttons won't appear. The raw intake data (name, phone, description) is still
visible so staff can create the job manually. visible so staff can create the record manually.
</div> </div>
</section> </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="#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="#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="#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="#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="#reviewing">Reviewing Submissions</a>
<a class="nav-link py-1 px-3" href="#troubleshooting">Troubleshooting</a> <a class="nav-link py-1 px-3" href="#troubleshooting">Troubleshooting</a>
@@ -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">