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:
@@ -32,7 +32,9 @@ public class InvoiceDto
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public string? CustomerEmail { get; set; }
|
||||
public string? CustomerPhone { get; set; }
|
||||
public string? CustomerMobilePhone { get; set; }
|
||||
public bool CustomerNotifyByEmail { get; set; }
|
||||
public bool CustomerNotifyBySms { get; set; }
|
||||
public string? PreparedById { get; set; }
|
||||
public string? PreparedByName { get; set; }
|
||||
public InvoiceStatus Status { get; set; }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -58,7 +58,7 @@ public interface INotificationService
|
||||
/// Notify customer when an invoice has been sent.
|
||||
/// Optionally includes an online payment link in the email body.
|
||||
/// </summary>
|
||||
Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null);
|
||||
Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null, bool sendSms = false, string? viewUrl = null);
|
||||
|
||||
/// <summary>
|
||||
/// Notify customer (internal) when a payment has been recorded on an invoice.
|
||||
|
||||
@@ -28,7 +28,9 @@ public class InvoiceProfile : Profile
|
||||
? (s.Customer.BillingEmail ?? s.Customer.Email)
|
||||
: null))
|
||||
.ForMember(d => d.CustomerPhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.Phone : null))
|
||||
.ForMember(d => d.CustomerMobilePhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.MobilePhone : null))
|
||||
.ForMember(d => d.CustomerNotifyByEmail, o => o.MapFrom(s => s.Customer == null || s.Customer.NotifyByEmail))
|
||||
.ForMember(d => d.CustomerNotifyBySms, o => o.MapFrom(s => s.Customer != null && s.Customer.NotifyBySms))
|
||||
.ForMember(d => d.PreparedByName, o => o.MapFrom(s => s.PreparedBy != null
|
||||
? $"{s.PreparedBy.FirstName} {s.PreparedBy.LastName}".Trim()
|
||||
: null))
|
||||
|
||||
Reference in New Issue
Block a user