Compare commits
3 Commits
d134dd51e5
...
b69ff6db3a
| Author | SHA1 | Date | |
|---|---|---|---|
| b69ff6db3a | |||
| 66231822af | |||
| d5ad9fa073 |
@@ -59,6 +59,9 @@ public class CompanyPreferencesDto
|
||||
// Blank Work Order PDF Template
|
||||
public string WoAccentColor { get; set; } = "#374151";
|
||||
public string? WoTerms { get; set; }
|
||||
|
||||
// Kiosk settings
|
||||
public string KioskIntakeOutput { get; set; } = "Quote";
|
||||
}
|
||||
|
||||
public class UpdateAppDefaultsDto
|
||||
@@ -136,3 +139,11 @@ public class UpdateWorkOrderTemplateDto
|
||||
public string WoAccentColor { get; set; } = "#374151";
|
||||
[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 int? LinkedCustomerId { get; set; }
|
||||
public int? LinkedJobId { get; set; }
|
||||
public int? LinkedQuoteId { get; set; }
|
||||
public string? RemoteLinkEmail { get; set; }
|
||||
|
||||
public string CustomerFullName => $"{CustomerFirstName} {CustomerLastName}".Trim();
|
||||
public string JobDescriptionSnippet =>
|
||||
JobDescription.Length > 80 ? JobDescription[..80] + "…" : JobDescription;
|
||||
public bool IsConverted => LinkedJobId.HasValue;
|
||||
public bool IsConverted => LinkedJobId.HasValue || LinkedQuoteId.HasValue;
|
||||
public bool IsExpired => Status == KioskSessionStatus.Expired ||
|
||||
(Status == KioskSessionStatus.Active && DateTime.UtcNow > ExpiresAt);
|
||||
}
|
||||
|
||||
@@ -54,5 +54,6 @@ public class CompanyProfile : Profile
|
||||
CreateMap<UpdateQuoteTemplateDto, CompanyPreferences>();
|
||||
CreateMap<UpdateInvoiceTemplateDto, 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>
|
||||
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
|
||||
/// <summary>Selected first-workflow path: quote_first or job_first. Null until chosen.</summary>
|
||||
public string? OnboardingPath { get; set; }
|
||||
|
||||
@@ -36,7 +36,10 @@ public class KioskSession : BaseEntity
|
||||
|
||||
// ── Outcome ───────────────────────────────────────────────────────────────
|
||||
public int? LinkedCustomerId { get; set; }
|
||||
/// <summary>Set when KioskIntakeOutput = "Job". Null when a Quote was created instead.</summary>
|
||||
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; }
|
||||
/// <summary>Sessions auto-expire 2 h after creation (InPerson) or 48 h (Remote). ExpiresAt is set at creation.</summary>
|
||||
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")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("KioskIntakeOutput")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("LogRetentionDays")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -5637,6 +5641,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<int?>("LinkedJobId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int?>("LinkedQuoteId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("RemoteLinkEmail")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -6692,7 +6699,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
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",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -6703,7 +6710,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
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",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -6714,7 +6721,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
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",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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>
|
||||
/// Issues a standalone credit memo and increments the customer's CreditBalance.
|
||||
/// Restricted to CompanyAdmin because credits affect the financial ledger. The memo
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1270,7 +1270,11 @@ public static class HelpKnowledgeBase
|
||||
|
||||
**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):**
|
||||
1. Go to Settings → Kiosk Setup (or /Kiosk/Activate)
|
||||
@@ -1293,23 +1297,24 @@ public static class HelpKnowledgeBase
|
||||
|
||||
**What happens on submission:**
|
||||
- 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
|
||||
- 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):**
|
||||
- Filter tabs: All / Submitted / Pending / Expired
|
||||
- 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
|
||||
- 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.
|
||||
|
||||
**Troubleshooting:**
|
||||
- "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
|
||||
- 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
|
||||
- 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:**
|
||||
Create Quote for prospect → Quote Approved → Convert Prospect to Customer → Convert Quote to Job
|
||||
|
||||
**Walk-in customer intake (kiosk):**
|
||||
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
|
||||
**Walk-in customer intake (kiosk — Quote mode):**
|
||||
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):**
|
||||
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):**
|
||||
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-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>
|
||||
|
||||
}
|
||||
|
||||
@@ -175,7 +175,8 @@
|
||||
}
|
||||
@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
|
||||
</span>
|
||||
}
|
||||
@@ -184,6 +185,11 @@
|
||||
<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
|
||||
</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>
|
||||
|
||||
@@ -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 & 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>
|
||||
<p>
|
||||
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
|
||||
<strong>customer record</strong> are automatically created, and your team receives an in-app
|
||||
notification so they know someone is waiting.
|
||||
— no staff assistance required. When they're done, a <strong>customer record</strong> is automatically
|
||||
created (or matched to an existing one), a <strong>Draft Quote or Pending Job</strong> is created
|
||||
depending on your setting, and your team receives an in-app notification.
|
||||
</p>
|
||||
<p>
|
||||
The kiosk runs as a browser page (optimised for iPad and Android tablets) and can also send a
|
||||
@@ -89,6 +89,28 @@
|
||||
</ol>
|
||||
</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">
|
||||
<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
|
||||
@@ -96,9 +118,18 @@
|
||||
<p>When a customer submits their intake form, the system automatically:</p>
|
||||
<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>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>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>
|
||||
</section>
|
||||
|
||||
@@ -116,14 +147,15 @@
|
||||
<li>Job description snippet</li>
|
||||
<li>Session type (In-Person or Remote) and status badge</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>
|
||||
</ul>
|
||||
<div class="alert alert-info alert-permanent">
|
||||
<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
|
||||
View Job / Customer buttons won't appear. The raw intake data (name, phone, description) is still
|
||||
visible so staff can create the job manually.
|
||||
action buttons won't appear. The raw intake data (name, phone, description) is still
|
||||
visible so staff can create the record manually.
|
||||
</div>
|
||||
</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="#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="#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="#reviewing">Reviewing Submissions</a>
|
||||
<a class="nav-link py-1 px-3" href="#troubleshooting">Troubleshooting</a>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user