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,88 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>@(ViewData["Title"] ?? "Customer Intake") — @(ViewBag.CompanyName ?? "Intake Form")</title>
<link href="~/lib/bootstrap/css/bootstrap.min.css" rel="stylesheet" />
<link rel="stylesheet" href="~/lib/bootstrap-icons/font/bootstrap-icons.css" />
<link rel="stylesheet" href="~/css/kiosk.css" />
@await RenderSectionAsync("Styles", required: false)
</head>
<body class="kiosk-body">
@{
int kioskStep = ViewBag.KioskStep ?? 0; // 1, 2, or 3 — 0 means no step dots
int kioskSteps = ViewBag.KioskSteps ?? 3;
}
<div class="container py-4" style="max-width:720px;">
@* Logo *@
<div class="text-center mb-3">
@if (!string.IsNullOrEmpty(ViewBag.CompanyLogoUrl as string))
{
<img src="@ViewBag.CompanyLogoUrl" alt="@ViewBag.CompanyName"
style="max-height:80px;max-width:220px;object-fit:contain;" />
}
else
{
<span class="fw-bold fs-5 text-muted">@ViewBag.CompanyName</span>
}
</div>
@* Step dots *@
@if (kioskStep > 0)
{
<div class="kiosk-steps mb-4" aria-label="Step @kioskStep of @kioskSteps">
@for (int i = 1; i <= kioskSteps; i++)
{
string dotClass = i < kioskStep ? "done" : (i == kioskStep ? "active" : "");
<div class="kiosk-step-dot @dotClass" title="Step @i"></div>
}
</div>
}
@* Validation summary *@
@if (ViewData.ModelState.IsValid == false)
{
<div class="alert alert-danger alert-permanent mb-4">
<i class="bi bi-exclamation-triangle me-2"></i>
Please correct the highlighted fields below.
</div>
}
@RenderBody()
</div>
@* Inactivity timer — redirect to Welcome after 5 minutes of no input *@
@{
bool showInactivityTimer = (bool)(ViewBag.ShowInactivityTimer ?? true);
string welcomeUrl = ViewBag.WelcomeUrl as string ?? "/Kiosk/Welcome";
}
@if (showInactivityTimer)
{
<script>
(function () {
var TIMEOUT_MS = 5 * 60 * 1000;
var timer;
function reset() {
clearTimeout(timer);
timer = setTimeout(function () {
window.location.href = "@Html.Raw(welcomeUrl)";
}, TIMEOUT_MS);
}
["touchstart", "touchmove", "click", "keydown", "scroll"].forEach(function (evt) {
document.addEventListener(evt, reset, { passive: true });
});
reset();
})();
</script>
}
<script src="~/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="~/lib/microsoft/signalr/dist/browser/signalr.min.js"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>
@@ -1136,6 +1136,13 @@
<span>Daily Board</span>
</a>
}
@if (hasJobs)
{
<a asp-controller="Kiosk" asp-action="Intakes" class="nav-link" data-nav="ops">
<i class="bi bi-tablet"></i>
<span>Intake Sessions</span>
</a>
}
@* ── Billing & Payments ───────────────────────────────────── *@
@if (hasInvoices)
@@ -1492,6 +1499,7 @@
<li><a class="dropdown-item" asp-controller="CompanyUsers" asp-action="Index"><i class="bi bi-people-fill me-2"></i>Manage Users</a></li>
<li><a class="dropdown-item" asp-controller="PricingTiers" asp-action="Index"><i class="bi bi-tags me-2"></i>Pricing Tiers</a></li>
<li><a class="dropdown-item" asp-controller="TaxRates" asp-action="Index"><i class="bi bi-percent me-2"></i>Tax Rates</a></li>
<li><a class="dropdown-item" asp-controller="Kiosk" asp-action="Activate"><i class="bi bi-tablet me-2"></i>Kiosk Setup</a></li>
}
<li><hr class="dropdown-divider"></li>
@if (gearIsAdmin)