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
@@ -33,6 +33,14 @@
</p>
<div class="d-flex gap-2 flex-wrap align-items-center">
<a asp-controller="Jobs" asp-action="Board" class="btn btn-sm btn-primary">Open Jobs Board</a>
<button type="button" class="btn btn-sm btn-outline-info" id="btnStartIntake"
title="Push the intake form to the front-desk tablet">
<i class="bi bi-tablet me-1"></i>Start Intake
</button>
<a href="/Kiosk/SendRemoteLink" class="btn btn-sm btn-outline-secondary"
title="Email a customer a link to fill out the intake form on their own device">
<i class="bi bi-envelope-at me-1"></i>Remote Link
</a>
@if (!string.IsNullOrEmpty(Model.TipOfTheDay))
{
<span class="text-muted d-none d-xl-inline" style="font-size:0.73rem;"><i class="bi bi-lightbulb me-1"></i>@Model.TipOfTheDay</span>
@@ -827,6 +835,40 @@
@section Scripts {
<script src="~/js/shop-progress-widget.js" asp-append-version="true"></script>
<script>
// Start Intake — pushes SignalR event to front-desk tablet
document.getElementById('btnStartIntake')?.addEventListener('click', async function () {
const btn = this;
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Sending…';
try {
const res = await fetch('/Kiosk/StartSession', {
method: 'POST',
headers: { 'RequestVerificationToken': token, 'Content-Type': 'application/json' }
});
const data = await res.json();
if (data.success) {
btn.innerHTML = '<i class="bi bi-check-circle me-1"></i>Sent!';
btn.classList.replace('btn-outline-info', 'btn-success');
setTimeout(() => {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-tablet me-1"></i>Start Intake';
btn.classList.replace('btn-success', 'btn-outline-info');
}, 3000);
} else {
throw new Error('Server returned failure');
}
} catch (err) {
btn.innerHTML = '<i class="bi bi-exclamation-triangle me-1"></i>Failed';
btn.classList.replace('btn-outline-info', 'btn-outline-danger');
setTimeout(() => {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-tablet me-1"></i>Start Intake';
btn.classList.replace('btn-outline-danger', 'btn-outline-info');
}, 3000);
}
});
// Powder Orders - Mark as Ordered
document.querySelectorAll('.mark-ordered-btn').forEach(btn => {
btn.addEventListener('click', async function () {