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,87 @@
using System.ComponentModel.DataAnnotations;
using PowderCoating.Core.Enums;
namespace PowderCoating.Application.DTOs.Kiosk;
// ── Staff-facing ──────────────────────────────────────────────────────────────
/// <summary>Input for sending a remote intake link to a customer by email.</summary>
public class SendRemoteLinkDto
{
[Required, EmailAddress]
public string Email { get; set; } = string.Empty;
/// <summary>Optional — used to personalise the email greeting.</summary>
public string? CustomerName { get; set; }
}
// ── Customer-facing step DTOs ─────────────────────────────────────────────────
/// <summary>Step 1 — Contact information submitted by the customer.</summary>
public class SubmitKioskContactDto
{
[Required, MaxLength(100)]
public string FirstName { get; set; } = string.Empty;
[Required, MaxLength(100)]
public string LastName { get; set; } = string.Empty;
[Required, Phone]
public string Phone { get; set; } = string.Empty;
[Required, EmailAddress]
public string Email { get; set; } = string.Empty;
public bool IsReturningCustomer { get; set; }
}
/// <summary>Step 2 — Job description submitted by the customer.</summary>
public class SubmitKioskJobDto
{
[Required, MaxLength(2000)]
public string JobDescription { get; set; } = string.Empty;
public string? HowDidYouHearAboutUs { get; set; }
}
/// <summary>Step 3 — Terms agreement (+ optional drawn signature for in-person sessions).</summary>
public class SubmitKioskTermsDto
{
[Required]
[Range(typeof(bool), "true", "true", ErrorMessage = "You must agree to the terms to continue.")]
public bool AgreedToTerms { get; set; }
public bool SmsOptIn { get; set; }
/// <summary>Base-64 PNG from signature_pad; required for InPerson sessions, null for Remote.</summary>
public string? SignatureDataBase64 { get; set; }
}
// ── Staff review list ─────────────────────────────────────────────────────────
/// <summary>One row in the Kiosk Intakes staff review list.</summary>
public class KioskSessionListDto
{
public int Id { get; set; }
public Guid SessionToken { get; set; }
public KioskSessionType SessionType { get; set; }
public KioskSessionStatus Status { get; set; }
public string CustomerFirstName { get; set; } = string.Empty;
public string CustomerLastName { get; set; } = string.Empty;
public string CustomerEmail { get; set; } = string.Empty;
public string CustomerPhone { get; set; } = string.Empty;
public string JobDescription { get; set; } = string.Empty;
public bool SmsOptIn { get; set; }
public DateTime? SubmittedAt { get; set; }
public DateTime ExpiresAt { get; set; }
public int? LinkedCustomerId { get; set; }
public int? LinkedJobId { 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 IsExpired => Status == KioskSessionStatus.Expired ||
(Status == KioskSessionStatus.Active && DateTime.UtcNow > ExpiresAt);
}