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,182 @@
@model InvoiceViewViewModel
@using PowderCoating.Core.Enums
@{
Layout = "~/Views/Shared/_QuoteApprovalLayout.cshtml";
ViewData["Title"] = $"Invoice {Model.InvoiceNumber}";
var isPaid = Model.BalanceDue <= 0;
}
<div class="container py-4" style="max-width:780px;">
@* ── Header ── *@
<div class="text-center mb-4">
@if (!string.IsNullOrEmpty(Model.LogoFilePath))
{
<img src="/media/@(Model.LogoFilePath.TrimStart('/'))" alt="@Model.CompanyName" style="max-height:80px;max-width:240px;object-fit:contain;" class="mb-3" />
}
else
{
<img src="/images/pcl-logo.png" alt="@Model.CompanyName" style="max-height:60px;" class="mb-3" />
}
<h4 class="fw-semibold mb-0">@Model.CompanyName</h4>
@if (!string.IsNullOrEmpty(Model.CompanyPhone))
{
<p class="text-muted small mb-0">@Model.CompanyPhone</p>
}
@if (!string.IsNullOrEmpty(Model.CompanyAddress))
{
<p class="text-muted small">@Model.CompanyAddress</p>
}
</div>
@* ── Invoice meta ── *@
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<div class="row g-3">
<div class="col-6">
<p class="text-muted small mb-1">Invoice</p>
<p class="fw-semibold mb-0">@Model.InvoiceNumber</p>
</div>
<div class="col-6 text-end">
<p class="text-muted small mb-1">Date</p>
<p class="fw-semibold mb-0">@Model.InvoiceDate.ToString("MMM d, yyyy")</p>
</div>
<div class="col-6">
<p class="text-muted small mb-1">Bill To</p>
<p class="fw-semibold mb-0">@Model.CustomerName</p>
</div>
@if (Model.DueDate.HasValue)
{
<div class="col-6 text-end">
<p class="text-muted small mb-1">Due Date</p>
<p class="fw-semibold mb-0 @(Model.DueDate < DateTime.UtcNow && !isPaid ? "text-danger" : "")">
@Model.DueDate.Value.ToString("MMM d, yyyy")
</p>
</div>
}
@if (!string.IsNullOrEmpty(Model.JobNumber))
{
<div class="col-6">
<p class="text-muted small mb-1">Job</p>
<p class="fw-semibold mb-0">@Model.JobNumber</p>
</div>
}
</div>
</div>
</div>
@* ── Line items ── *@
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-0">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th class="ps-3">Description</th>
<th class="text-center" style="width:70px;">Qty</th>
<th class="text-end" style="width:100px;">Unit Price</th>
<th class="text-end pe-3" style="width:110px;">Total</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.LineItems)
{
<tr>
<td class="ps-3">@item.Description</td>
<td class="text-center">@item.Quantity.ToString("G29")</td>
<td class="text-end">@item.UnitPrice.ToString("C")</td>
<td class="text-end pe-3">@item.TotalPrice.ToString("C")</td>
</tr>
}
</tbody>
</table>
</div>
</div>
@* ── Totals ── *@
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<div class="d-flex justify-content-between mb-1">
<span class="text-muted">Subtotal</span>
<span>@Model.SubTotal.ToString("C")</span>
</div>
@if (Model.DiscountAmount > 0)
{
<div class="d-flex justify-content-between mb-1 text-success">
<span>Discount</span>
<span>-@Model.DiscountAmount.ToString("C")</span>
</div>
}
@if (Model.TaxAmount > 0)
{
<div class="d-flex justify-content-between mb-1">
<span class="text-muted">Tax (@Model.TaxPercent.ToString("0.##")%)</span>
<span>@Model.TaxAmount.ToString("C")</span>
</div>
}
<hr class="my-2" />
<div class="d-flex justify-content-between fw-semibold">
<span>Total</span>
<span>@Model.Total.ToString("C")</span>
</div>
@if (Model.AmountPaid > 0)
{
<div class="d-flex justify-content-between text-success mt-1">
<span>Amount Paid</span>
<span>-@Model.AmountPaid.ToString("C")</span>
</div>
<hr class="my-2" />
<div class="d-flex justify-content-between fw-bold fs-5 @(isPaid ? "text-success" : "text-danger")">
<span>Balance Due</span>
<span>@Model.BalanceDue.ToString("C")</span>
</div>
}
</div>
</div>
@* ── Pay button ── *@
@if (!isPaid && !string.IsNullOrEmpty(Model.PaymentUrl))
{
<div class="text-center mb-4">
<a href="@Model.PaymentUrl" class="btn btn-success btn-lg px-5">
<i class="bi bi-credit-card me-2"></i>Pay @Model.BalanceDue.ToString("C") Online
</a>
<p class="text-muted small mt-2">Secure payment powered by Stripe. This pay link expires in 5 days.</p>
</div>
}
else if (isPaid)
{
<div class="alert alert-success text-center" role="alert">
<i class="bi bi-check-circle-fill me-2"></i>This invoice has been paid in full. Thank you!
</div>
}
else
{
<div class="alert alert-info text-center" role="alert">
<i class="bi bi-info-circle me-2"></i>To arrange payment, please contact @Model.CompanyName@(!string.IsNullOrEmpty(Model.CompanyPhone) ? $" at {Model.CompanyPhone}" : "").
</div>
}
@* ── Notes / Terms ── *@
@if (!string.IsNullOrEmpty(Model.Notes))
{
<div class="card border-0 shadow-sm mb-3">
<div class="card-body">
<p class="text-muted small mb-1 fw-semibold">Notes</p>
<p class="mb-0 small">@Model.Notes</p>
</div>
</div>
}
@if (!string.IsNullOrEmpty(Model.Terms))
{
<div class="card border-0 shadow-sm mb-3">
<div class="card-body">
<p class="text-muted small mb-1 fw-semibold">Payment Terms</p>
<p class="mb-0 small">@Model.Terms</p>
</div>
</div>
}
<p class="text-center text-muted small mt-4">
Questions? Contact @Model.CompanyName@(!string.IsNullOrEmpty(Model.CompanyPhone) ? $" at {Model.CompanyPhone}" : "").
</p>
</div>