Add invoice SMS notifications and customer intake kiosk

Invoice SMS:
- Send Invoice modal now prompts Email/SMS/Both based on customer contact data
- New /invoice/{token} customer-facing view page with full line items and pay button
- PublicViewToken (permanent) added to Invoice; separate from expiring PaymentLinkToken
- InvoiceSent SMS default template added; customizable via Notification Templates settings
- {{viewUrl}} placeholder documented in template editor

Customer Intake Kiosk:
- Tablet kiosk flow: Contact → Job → Terms/Signature → Confirmation
- Remote link mode for off-site customers (lighter form, no signature)
- KioskHub (AllowAnonymous SignalR) for staff-to-tablet push without login
- Staff activates tablet via cookie; sends remote link manually
- Submitted sessions create Customer + Job automatically; fires in-app notification

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-13 16:25:27 -04:00
parent 27bfd4db4d
commit 6a918c2afc
41 changed files with 24265 additions and 23 deletions
@@ -0,0 +1,98 @@
@{
ViewData["Title"] = "Kiosk Setup";
bool isActivated = ViewBag.IsActivated as bool? ?? false;
}
<div class="container-fluid px-4">
<div class="d-flex align-items-center gap-3 mb-4">
<i class="bi bi-tablet fs-3 text-primary"></i>
<div>
<h1 class="h3 fw-bold mb-0">Kiosk Setup</h1>
<p class="text-muted mb-0">Configure the front-desk intake tablet</p>
</div>
</div>
@if (TempData["Success"] != null)
{
<div class="alert alert-success alert-permanent mb-4">
<i class="bi bi-check-circle me-2"></i> @TempData["Success"]
</div>
}
@if (TempData["Error"] != null)
{
<div class="alert alert-danger alert-permanent mb-4">
<i class="bi bi-exclamation-triangle me-2"></i> @TempData["Error"]
</div>
}
<div class="row g-4">
@* Status card *@
<div class="col-md-6">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title fw-semibold mb-3">Current Status</h5>
@if (isActivated)
{
<div class="d-flex align-items-center gap-2 mb-3">
<span class="badge bg-success fs-6 px-3 py-2">
<i class="bi bi-check-circle me-1"></i> Active
</span>
</div>
<p class="text-muted">
A kiosk device is currently activated. The tablet will respond to
"Start Intake" commands from your staff.
</p>
<form method="post" asp-action="Activate">
@Html.AntiForgeryToken()
<input type="hidden" name="action" value="deactivate" />
<button type="submit" class="btn btn-outline-danger"
onclick="return confirm('Deactivate the kiosk? The tablet will no longer receive intake requests.');">
<i class="bi bi-tablet me-1"></i> Deactivate Kiosk
</button>
</form>
}
else
{
<div class="d-flex align-items-center gap-2 mb-3">
<span class="badge bg-secondary fs-6 px-3 py-2">
<i class="bi bi-dash-circle me-1"></i> Not Activated
</span>
</div>
<p class="text-muted">
No kiosk device is activated. Click below to activate this browser
session as the kiosk device.
</p>
<form method="post" asp-action="Activate">
@Html.AntiForgeryToken()
<input type="hidden" name="action" value="activate" />
<button type="submit" class="btn btn-primary">
<i class="bi bi-tablet me-1"></i> Activate This Device
</button>
</form>
}
</div>
</div>
</div>
@* Instructions card *@
<div class="col-md-6">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title fw-semibold mb-3">Setup Instructions</h5>
<ol class="text-muted" style="line-height:2;">
<li>Open this page on the <strong>tablet</strong> and tap <em>Activate This Device</em>.</li>
<li>After activation, navigate to <code>/Kiosk/Welcome</code> on the tablet.</li>
<li>Bookmark that page so it survives a browser restart.</li>
<li>Keep the tablet browser open — SignalR maintains a live connection.</li>
<li>Use <em>Start Customer Intake</em> on the Dashboard or Jobs list to push a session to the tablet.</li>
</ol>
<div class="alert alert-info mb-0">
<i class="bi bi-info-circle me-2"></i>
Only one device can be active at a time. Re-activating replaces the previous device token.
</div>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,53 @@
@{
Layout = "~/Views/Shared/_KioskLayout.cshtml";
ViewData["Title"] = "Thank You";
bool isInPerson = ViewBag.IsInPerson as bool? ?? false;
string firstName = ViewBag.FirstName as string ?? "there";
}
<div class="kiosk-confirmation py-5">
<div class="kiosk-confirmation-icon">
<i class="bi bi-check-circle-fill"></i>
</div>
<h2 class="fw-bold" style="font-size:2rem;">Thank you, @firstName!</h2>
@if (isInPerson)
{
<p class="text-muted mt-2" style="font-size:1.1rem;">
A team member will be right with you.
</p>
<p class="kiosk-countdown" id="countdown-msg">
Returning to the welcome screen in <span id="countdown">30</span> seconds…
</p>
}
else
{
<p class="text-muted mt-2" style="font-size:1.1rem;">
We've received your intake form and will be in touch soon.
</p>
<p class="text-muted mt-4" style="font-size:0.95rem;">
You can close this window.
</p>
}
</div>
@if (isInPerson)
{
@section Scripts {
<script>
(function () {
var secs = 30;
var el = document.getElementById("countdown");
var interval = setInterval(function () {
secs--;
if (el) el.textContent = secs;
if (secs <= 0) {
clearInterval(interval);
window.location.href = "@ViewBag.WelcomeUrl";
}
}, 1000);
})();
</script>
}
}
@@ -0,0 +1,59 @@
@model PowderCoating.Application.DTOs.Kiosk.SubmitKioskContactDto
@{
Layout = "~/Views/Shared/_KioskLayout.cshtml";
ViewData["Title"] = "Your Information";
var token = ViewBag.SessionToken as Guid? ?? Guid.Empty;
}
<div class="kiosk-card">
<h2 class="fw-bold mb-1" style="font-size:1.6rem;">Tell us about yourself</h2>
<p class="text-muted mb-4">All fields are required.</p>
<form method="post" action="/Kiosk/Intake/@token/Contact" id="contactForm">
@Html.AntiForgeryToken()
<div class="row g-3">
<div class="col-sm-6">
<label asp-for="FirstName" class="form-label">First Name</label>
<input asp-for="FirstName" class="form-control" autocomplete="given-name"
autocapitalize="words" spellcheck="false" placeholder="Jane" />
<span asp-validation-for="FirstName" class="text-danger small"></span>
</div>
<div class="col-sm-6">
<label asp-for="LastName" class="form-label">Last Name</label>
<input asp-for="LastName" class="form-control" autocomplete="family-name"
autocapitalize="words" spellcheck="false" placeholder="Smith" />
<span asp-validation-for="LastName" class="text-danger small"></span>
</div>
</div>
<div class="mt-3">
<label asp-for="Phone" class="form-label">Phone Number</label>
<input asp-for="Phone" class="form-control" type="tel" inputmode="tel"
autocomplete="tel" placeholder="(555) 555-0100" />
<span asp-validation-for="Phone" class="text-danger small"></span>
</div>
<div class="mt-3">
<label asp-for="Email" class="form-label">Email Address</label>
<input asp-for="Email" class="form-control" type="email" inputmode="email"
autocomplete="email" placeholder="jane@example.com" />
<span asp-validation-for="Email" class="text-danger small"></span>
</div>
<div class="mt-4 p-3 rounded-3" style="background:#f1f5f9;">
<div class="form-check">
<input asp-for="IsReturningCustomer" class="form-check-input" type="checkbox" />
<label asp-for="IsReturningCustomer" class="form-check-label">
I've been a customer before
</label>
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary kiosk-btn">
Continue <i class="bi bi-arrow-right ms-2"></i>
</button>
</div>
</form>
</div>
@@ -0,0 +1,46 @@
@model PowderCoating.Application.DTOs.Kiosk.SubmitKioskJobDto
@{
Layout = "~/Views/Shared/_KioskLayout.cshtml";
ViewData["Title"] = "About Your Project";
var token = ViewBag.SessionToken as Guid? ?? Guid.Empty;
}
<div class="kiosk-card">
<h2 class="fw-bold mb-1" style="font-size:1.6rem;">What brings you in?</h2>
<p class="text-muted mb-4">Tell us a little about what you need coated.</p>
<form method="post" action="/Kiosk/Intake/@token/Job">
@Html.AntiForgeryToken()
<div class="mb-3">
<label asp-for="JobDescription" class="form-label">Describe your project</label>
<textarea asp-for="JobDescription" class="form-control" rows="5"
placeholder="e.g. Motorcycle frame, two-tone black and chrome, remove old coating first..."
style="min-height:160px;resize:none;"></textarea>
<span asp-validation-for="JobDescription" class="text-danger small"></span>
</div>
<div class="mb-4">
<label asp-for="HowDidYouHearAboutUs" class="form-label">How did you hear about us? <span class="text-muted fw-normal">(optional)</span></label>
<select asp-for="HowDidYouHearAboutUs" class="form-select">
<option value="">— Select one —</option>
<option>Google / Online Search</option>
<option>Friend or Family Referral</option>
<option>Social Media</option>
<option>Drove by the shop</option>
<option>Returning Customer</option>
<option>Other</option>
</select>
</div>
<div class="d-flex gap-3">
<a href="/Kiosk/Intake/@token/Contact" 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-arrow-left me-1"></i> Back
</a>
<button type="submit" class="btn btn-primary kiosk-btn">
Continue <i class="bi bi-arrow-right ms-2"></i>
</button>
</div>
</form>
</div>
@@ -0,0 +1,98 @@
@model PowderCoating.Application.DTOs.Kiosk.SubmitKioskTermsDto
@{
Layout = "~/Views/Shared/_KioskLayout.cshtml";
ViewData["Title"] = "Terms & Consent";
var token = ViewBag.SessionToken as Guid? ?? Guid.Empty;
bool isInPerson = ViewBag.IsInPerson as bool? ?? false;
}
<div class="kiosk-card">
<h2 class="fw-bold mb-1" style="font-size:1.6rem;">Terms & Consent</h2>
<p class="text-muted mb-4">Please read and agree to the following before we proceed.</p>
<form method="post" action="/Kiosk/Intake/@token/Terms" id="termsForm">
@Html.AntiForgeryToken()
@* Terms scroll box *@
<div class="kiosk-terms-scroll mb-4">
<strong>Work Authorization &amp; Liability Waiver</strong>
<p class="mt-2">
By signing below (or checking the box), you authorize @(ViewBag.CompanyName ?? "this shop")
to perform the powder coating services described in your intake form.
</p>
<p>
You acknowledge that you are the owner of the items submitted for coating, or you
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>
<p class="mb-0">
You agree to comply with all pickup and payment terms provided by the shop.
</p>
</div>
@* SMS consent — separate checkbox per plan *@
<div class="p-3 rounded-3 mb-3" style="background:#f0f9ff;border:1px solid #bae6fd;">
<div class="form-check">
<input asp-for="SmsOptIn" class="form-check-input" type="checkbox" />
<label asp-for="SmsOptIn" class="form-check-label">
I consent to receive SMS text messages with updates about my order.
<span class="text-muted d-block mt-1" style="font-size:0.85rem;">
Message and data rates may apply. Reply STOP to opt out at any time.
</span>
</label>
</div>
</div>
@* Terms agreement *@
<div class="p-3 rounded-3 mb-4" style="background:#f8fafc;border:1px solid #e2e8f0;">
<div class="form-check">
<input asp-for="AgreedToTerms" class="form-check-input" type="checkbox" required />
<label asp-for="AgreedToTerms" class="form-check-label fw-semibold">
I have read and agree to the terms above.
</label>
<span asp-validation-for="AgreedToTerms" class="text-danger d-block small mt-1"></span>
</div>
</div>
@* Signature pad — in-person only *@
@if (isInPerson)
{
<div class="mb-4">
<label class="form-label fw-semibold">Your Signature</label>
<canvas id="signatureCanvas"></canvas>
<div id="signatureError" class="text-danger small mt-1 d-none">
Please sign above before continuing.
</div>
<input type="hidden" id="SignatureDataBase64" name="SignatureDataBase64" />
<button type="button" id="clearSignatureBtn"
class="btn btn-sm btn-outline-secondary mt-2">
<i class="bi bi-eraser me-1"></i> Clear
</button>
</div>
}
<div class="d-flex gap-3">
<a href="/Kiosk/Intake/@token/Job" 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-arrow-left me-1"></i> Back
</a>
<button type="submit" class="btn btn-success kiosk-btn">
<i class="bi bi-check-circle me-2"></i> Submit
</button>
</div>
</form>
</div>
@if (isInPerson)
{
@section Scripts {
<script src="https://cdn.jsdelivr.net/npm/signature_pad@4.1.7/dist/signature_pad.umd.min.js"
integrity="sha384-bQMMRVcRi5vEIBLKnB4FY7tBOA9k/Qvd/9zSWMNO4h0zfB2qLj4DV2R/JyPAbF3"
crossorigin="anonymous"></script>
<script src="~/js/kiosk-terms.js"></script>
}
}
@@ -0,0 +1,163 @@
@model List<PowderCoating.Application.DTOs.Kiosk.KioskSessionListDto>
@using PowderCoating.Core.Enums
@{
ViewData["Title"] = "Customer Intakes";
string activeFilter = ViewBag.ActiveFilter as string ?? "all";
}
<div class="container-fluid px-4">
<div class="d-flex align-items-center justify-content-between mb-4 flex-wrap gap-2">
<div class="d-flex align-items-center gap-3">
<i class="bi bi-clipboard-check fs-3 text-primary"></i>
<div>
<h1 class="h3 fw-bold mb-0">Customer Intakes</h1>
<p class="text-muted mb-0">Walk-in and remote intake sessions</p>
</div>
</div>
<div class="d-flex gap-2">
<a href="/Kiosk/SendRemoteLink" class="btn btn-outline-primary btn-sm">
<i class="bi bi-envelope-at me-1"></i> Send Remote Link
</a>
</div>
</div>
@* Filter tabs *@
<ul class="nav nav-tabs mb-4">
<li class="nav-item">
<a class="nav-link @(activeFilter == "all" ? "active" : "")" href="?filter=all">All (@Model.Count)</a>
</li>
<li class="nav-item">
<a class="nav-link @(activeFilter == "submitted" ? "active" : "")" href="?filter=submitted">
Submitted (@Model.Count(d => d.Status == KioskSessionStatus.Submitted))
</a>
</li>
<li class="nav-item">
<a class="nav-link @(activeFilter == "active" ? "active" : "")" href="?filter=active">
Pending (@Model.Count(d => d.Status == KioskSessionStatus.Active && !d.IsExpired))
</a>
</li>
<li class="nav-item">
<a class="nav-link @(activeFilter == "expired" ? "active" : "")" href="?filter=expired">
Expired (@Model.Count(d => d.IsExpired))
</a>
</li>
</ul>
@if (!Model.Any())
{
<div class="text-center py-5 text-muted">
<i class="bi bi-inbox fs-1 mb-3 d-block"></i>
<p>No intake sessions found.</p>
</div>
}
else
{
<div class="card">
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th>Date</th>
<th>Customer</th>
<th>Contact</th>
<th>Project</th>
<th>Type</th>
<th>Status</th>
<th>SMS</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var s in Model)
{
<tr>
<td class="text-nowrap text-muted small">
@(s.SubmittedAt?.ToLocalTime().ToString("MM/dd/yy h:mm tt") ?? s.ExpiresAt.AddHours(-2).ToLocalTime().ToString("MM/dd/yy h:mm tt"))
</td>
<td>
<div class="fw-semibold">@s.CustomerFullName</div>
@if (s.LinkedCustomerId.HasValue)
{
<a href="/Customers/Details/@s.LinkedCustomerId" class="small text-success">
<i class="bi bi-person-check me-1"></i>Customer matched
</a>
}
</td>
<td class="small text-muted">
@if (!string.IsNullOrEmpty(s.CustomerPhone))
{
<div><i class="bi bi-telephone me-1"></i>@s.CustomerPhone</div>
}
@if (!string.IsNullOrEmpty(s.CustomerEmail))
{
<div><i class="bi bi-envelope me-1"></i>@s.CustomerEmail</div>
}
</td>
<td style="max-width:280px;">
<span class="text-truncate d-block" style="max-width:260px;"
title="@s.JobDescription">@s.JobDescriptionSnippet</span>
</td>
<td>
@if (s.SessionType == KioskSessionType.InPerson)
{
<span class="badge bg-primary-subtle text-primary">
<i class="bi bi-tablet me-1"></i>In-Person
</span>
}
else
{
<span class="badge bg-purple-subtle text-purple" style="background:#ede9fe;color:#6d28d9;">
<i class="bi bi-envelope me-1"></i>Remote
</span>
}
</td>
<td>
@if (s.Status == KioskSessionStatus.Submitted && s.IsConverted)
{
<span class="badge bg-success">Converted</span>
}
else if (s.Status == KioskSessionStatus.Submitted)
{
<span class="badge bg-info text-dark">Submitted</span>
}
else if (s.Status == KioskSessionStatus.Active && !s.IsExpired)
{
<span class="badge bg-warning text-dark">In Progress</span>
}
else
{
<span class="badge bg-secondary">Expired</span>
}
</td>
<td>
@if (s.SmsOptIn)
{
<i class="bi bi-check-circle-fill text-success" title="SMS opt-in"></i>
}
else
{
<i class="bi bi-dash text-muted"></i>
}
</td>
<td class="text-nowrap">
@if (s.LinkedJobId.HasValue)
{
<a href="/Jobs/Details/@s.LinkedJobId" class="btn btn-sm btn-outline-success me-1">
<i class="bi bi-briefcase me-1"></i>View Job
</a>
}
@if (s.LinkedCustomerId.HasValue)
{
<a href="/Customers/Details/@s.LinkedCustomerId" class="btn btn-sm btn-outline-primary">
<i class="bi bi-person me-1"></i>Customer
</a>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
</div>
@@ -0,0 +1,13 @@
@model string
@{
Layout = "~/Views/Shared/_KioskLayout.cshtml";
ViewData["Title"] = "Unable to Start";
ViewBag.ShowInactivityTimer = false;
}
<div class="kiosk-card text-center py-5">
<i class="bi bi-exclamation-triangle-fill text-warning" style="font-size:4rem;"></i>
<h2 class="mt-3 fw-bold">Something went wrong</h2>
<p class="text-muted mt-2">@Model</p>
<p class="mt-4 text-muted" style="font-size:0.9rem;">Please ask a staff member for assistance.</p>
</div>
@@ -0,0 +1,68 @@
@model PowderCoating.Application.DTOs.Kiosk.SendRemoteLinkDto
@{
ViewData["Title"] = "Send Intake Link";
}
<div class="container-fluid px-4">
<div class="d-flex align-items-center gap-3 mb-4">
<i class="bi bi-envelope-at fs-3 text-primary"></i>
<div>
<h1 class="h3 fw-bold mb-0">Send Remote Intake Link</h1>
<p class="text-muted mb-0">Email a customer an intake form they can fill out on their own device</p>
</div>
</div>
@if (TempData["Success"] != null)
{
<div class="alert alert-success alert-permanent mb-4">
<i class="bi bi-check-circle me-2"></i> @TempData["Success"]
</div>
}
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<form method="post" asp-action="SendRemoteLink">
@Html.AntiForgeryToken()
<div asp-validation-summary="ModelOnly" class="alert alert-danger alert-permanent mb-3"></div>
<div class="mb-3">
<label asp-for="Email" class="form-label fw-semibold">Customer Email Address</label>
<input asp-for="Email" class="form-control" type="email"
placeholder="customer@example.com" autofocus />
<span asp-validation-for="Email" class="text-danger small"></span>
</div>
<div class="mb-4">
<label asp-for="CustomerName" class="form-label fw-semibold">
Customer Name <span class="text-muted fw-normal">(optional)</span>
</label>
<input asp-for="CustomerName" class="form-control"
placeholder="Used to personalise the email greeting" />
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-send me-2"></i> Send Intake Link
</button>
<a href="/Dashboard" class="btn btn-link ms-2">Cancel</a>
</form>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card bg-light border-0">
<div class="card-body">
<h6 class="fw-semibold mb-2"><i class="bi bi-info-circle me-2 text-primary"></i>How it works</h6>
<ul class="text-muted small mb-0" style="line-height:1.8;">
<li>The customer receives an email with a unique, secure link.</li>
<li>They fill out their contact info and describe their project on their own phone or computer.</li>
<li>When they submit, a Pending job is automatically created and you're notified.</li>
<li>The link expires in 48 hours.</li>
</ul>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,32 @@
@{
Layout = "~/Views/Shared/_KioskLayout.cshtml";
ViewData["Title"] = "Welcome";
}
<div id="kiosk-welcome-root"
data-company-id="@ViewBag.CompanyId"
class="kiosk-welcome-screen">
@if (!string.IsNullOrEmpty(ViewBag.CompanyLogoUrl as string))
{
<img src="@ViewBag.CompanyLogoUrl"
alt="@ViewBag.CompanyName"
class="kiosk-welcome-logo" />
}
else
{
<h1 class="kiosk-welcome-title">@ViewBag.CompanyName</h1>
}
<p class="kiosk-welcome-subtitle">Welcome! A staff member will start your intake shortly.</p>
<div class="kiosk-idle-indicator">
<span id="kiosk-conn-dot" style="display:inline-block;width:10px;height:10px;
border-radius:50%;background:#16a34a;margin-right:6px;transition:background 0.3s;"></span>
Ready
</div>
</div>
@section Scripts {
<script src="~/js/kiosk-welcome.js"></script>
}