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,48 @@
"use strict";
(function () {
// ── Signature pad (InPerson sessions only) ─────────────────────────────────
const canvas = document.getElementById("signatureCanvas");
if (canvas) {
const pad = new SignaturePad(canvas, { penColor: "#1e293b" });
// Scale canvas to device pixel ratio for crisp rendering on high-DPI tablets
function resizeCanvas() {
const ratio = Math.max(window.devicePixelRatio || 1, 1);
canvas.width = canvas.offsetWidth * ratio;
canvas.height = canvas.offsetHeight * ratio;
canvas.getContext("2d").scale(ratio, ratio);
pad.clear();
}
resizeCanvas();
window.addEventListener("resize", resizeCanvas);
// Show visual feedback when the canvas has been signed
pad.addEventListener("endStroke", function () {
canvas.classList.add("signed");
});
document.getElementById("clearSignatureBtn")?.addEventListener("click", function () {
pad.clear();
canvas.classList.remove("signed");
});
// On submit: write base64 PNG to the hidden input
const form = document.getElementById("termsForm");
if (form) {
form.addEventListener("submit", function (e) {
const hiddenInput = document.getElementById("SignatureDataBase64");
if (hiddenInput) {
if (pad.isEmpty()) {
e.preventDefault();
const msg = document.getElementById("signatureError");
if (msg) msg.classList.remove("d-none");
canvas.classList.add("is-invalid");
return;
}
hiddenInput.value = pad.toDataURL("image/png");
}
});
}
}
})();