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))
|
||||
|
||||
@@ -123,6 +123,16 @@ public class Company : BaseEntity
|
||||
public byte[]? LogoData { get; set; } // Legacy - kept for backward compatibility
|
||||
public string? LogoContentType { get; set; } // Legacy - kept for backward compatibility
|
||||
public string? LogoFilePath { get; set; } // Filesystem path: /media/{CompanyId}/company-logo.{ext}
|
||||
|
||||
// Kiosk
|
||||
/// <summary>
|
||||
/// Random token written to a long-lived HttpOnly cookie on the front-desk tablet when the
|
||||
/// owner activates the kiosk. Kiosk routes validate this token against the cookie so the
|
||||
/// tablet can serve the intake form without requiring a logged-in user.
|
||||
/// Null = kiosk not activated. Regenerate to revoke the current device.
|
||||
/// </summary>
|
||||
public string? KioskActivationToken { get; set; }
|
||||
|
||||
// Navigation Properties
|
||||
public virtual ICollection<ApplicationUser> Users { get; set; } = new List<ApplicationUser>();
|
||||
public virtual ICollection<Customer> Customers { get; set; } = new List<Customer>();
|
||||
|
||||
@@ -28,6 +28,13 @@ public class Invoice : BaseEntity
|
||||
public decimal GiftCertificateRedeemed { get; set; } // Sum of gift certificate redemptions
|
||||
public decimal BalanceDue => Total - AmountPaid - CreditApplied - GiftCertificateRedeemed;
|
||||
|
||||
/// <summary>
|
||||
/// Permanent public token for the customer-facing invoice view page (/invoice/{token}).
|
||||
/// Generated when the invoice is first sent (regardless of Stripe status) and never expires.
|
||||
/// Distinct from PaymentLinkToken which is Stripe-gated and expires in 5 days.
|
||||
/// </summary>
|
||||
public string? PublicViewToken { get; set; }
|
||||
|
||||
// Online payments (Stripe Connect)
|
||||
public OnlinePaymentStatus OnlinePaymentStatus { get; set; } = OnlinePaymentStatus.NotApplicable;
|
||||
public string? PaymentLinkToken { get; set; } // Signed token for /pay/{token}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents one customer self-service intake session — either completed on the front-desk tablet
|
||||
/// (InPerson) or via an emailed link the customer fills out on their own device (Remote).
|
||||
/// Sessions are tenant-scoped and soft-deletable. Load anonymous sessions with ignoreQueryFilters:true.
|
||||
/// </summary>
|
||||
public class KioskSession : BaseEntity
|
||||
{
|
||||
/// <summary>URL-safe GUID used in all kiosk routes; unique across the table.</summary>
|
||||
public Guid SessionToken { get; set; } = Guid.NewGuid();
|
||||
|
||||
public KioskSessionType SessionType { get; set; }
|
||||
public KioskSessionStatus Status { get; set; } = KioskSessionStatus.Active;
|
||||
|
||||
// ── Step 1 — Contact ─────────────────────────────────────────────────────
|
||||
public string CustomerFirstName { get; set; } = string.Empty;
|
||||
public string CustomerLastName { get; set; } = string.Empty;
|
||||
public string CustomerPhone { get; set; } = string.Empty;
|
||||
public string CustomerEmail { get; set; } = string.Empty;
|
||||
public bool IsReturningCustomer { get; set; }
|
||||
|
||||
// ── Step 2 — Job Description ──────────────────────────────────────────────
|
||||
public string JobDescription { get; set; } = string.Empty;
|
||||
public string? HowDidYouHearAboutUs { get; set; }
|
||||
|
||||
// ── Step 3 — Terms & Consent ──────────────────────────────────────────────
|
||||
public bool AgreedToTerms { get; set; }
|
||||
public DateTime? AgreedToTermsAt { get; set; }
|
||||
/// <summary>Customer opted in to SMS order updates; sets Customer.NotifyBySms on submission.</summary>
|
||||
public bool SmsOptIn { get; set; }
|
||||
/// <summary>Base-64 PNG from signature_pad; null for Remote sessions (no drawn signature required).</summary>
|
||||
public string? SignatureDataBase64 { get; set; }
|
||||
|
||||
// ── Outcome ───────────────────────────────────────────────────────────────
|
||||
public int? LinkedCustomerId { get; set; }
|
||||
public int? LinkedJobId { get; set; }
|
||||
public DateTime? SubmittedAt { get; set; }
|
||||
/// <summary>Sessions auto-expire 2 h after creation (InPerson) or 48 h (Remote). ExpiresAt is set at creation.</summary>
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
|
||||
// ── Remote-only ───────────────────────────────────────────────────────────
|
||||
public string? RemoteLinkEmail { get; set; }
|
||||
public DateTime? RemoteLinkSentAt { get; set; }
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────────────────
|
||||
public virtual Customer? LinkedCustomer { get; set; }
|
||||
public virtual Job? LinkedJob { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace PowderCoating.Core.Enums;
|
||||
|
||||
public enum KioskSessionType
|
||||
{
|
||||
InPerson = 0,
|
||||
Remote = 1
|
||||
}
|
||||
|
||||
public enum KioskSessionStatus
|
||||
{
|
||||
Active = 0,
|
||||
Submitted = 1,
|
||||
Expired = 2,
|
||||
Cancelled = 3
|
||||
}
|
||||
@@ -154,6 +154,9 @@ public interface IUnitOfWork : IDisposable
|
||||
IRepository<GiftCertificate> GiftCertificates { get; }
|
||||
IRepository<GiftCertificateRedemption> GiftCertificateRedemptions { get; }
|
||||
|
||||
// Customer Intake Kiosk
|
||||
IRepository<KioskSession> KioskSessions { get; }
|
||||
|
||||
Task<int> SaveChangesAsync();
|
||||
Task<int> CompleteAsync(); // Alias for SaveChangesAsync
|
||||
|
||||
|
||||
@@ -367,6 +367,10 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
/// <summary>Prep-service definitions within a job template item.</summary>
|
||||
public DbSet<JobTemplateItemPrepService> JobTemplateItemPrepServices { get; set; }
|
||||
|
||||
// Customer Intake Kiosk
|
||||
/// <summary>Customer self-service intake sessions (walk-in tablet or remote email link); tenant-filtered with soft delete.</summary>
|
||||
public DbSet<KioskSession> KioskSessions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Platform-wide audit log capturing who changed what and when, across all tenants.
|
||||
/// No global query filter — SuperAdmin controllers query this directly.
|
||||
@@ -746,6 +750,24 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
modelBuilder.Entity<InAppNotification>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
|
||||
// Customer intake kiosk sessions — tenant-filtered + soft delete.
|
||||
// Anonymous intake routes must use ignoreQueryFilters:true when loading by SessionToken.
|
||||
modelBuilder.Entity<KioskSession>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<KioskSession>()
|
||||
.HasIndex(e => e.SessionToken)
|
||||
.IsUnique();
|
||||
modelBuilder.Entity<KioskSession>()
|
||||
.HasOne(k => k.LinkedCustomer)
|
||||
.WithMany()
|
||||
.HasForeignKey(k => k.LinkedCustomerId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
modelBuilder.Entity<KioskSession>()
|
||||
.HasOne(k => k.LinkedJob)
|
||||
.WithMany()
|
||||
.HasForeignKey(k => k.LinkedJobId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
// Account self-referencing hierarchy
|
||||
modelBuilder.Entity<Account>()
|
||||
.HasOne(a => a.ParentAccount)
|
||||
|
||||
@@ -967,6 +967,17 @@ New accounts walk through an 18-step setup wizard to configure company informati
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new NotificationTemplate
|
||||
{
|
||||
NotificationType = NotificationType.InvoiceSent,
|
||||
Channel = NotificationChannel.Sms,
|
||||
DisplayName = "Invoice Sent (SMS)",
|
||||
Subject = null,
|
||||
Body = "{{companyName}}: Invoice {{invoiceNumber}} for {{invoiceTotal}} is ready. View your invoice: {{viewUrl}} Reply STOP to opt out.",
|
||||
IsActive = true,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new NotificationTemplate
|
||||
{
|
||||
NotificationType = NotificationType.PaymentReceived,
|
||||
Channel = NotificationChannel.Email,
|
||||
|
||||
Generated
+10732
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,142 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddKioskIntakeSession : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "KioskActivationToken",
|
||||
table: "Companies",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "KioskSessions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
SessionToken = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
SessionType = table.Column<int>(type: "int", nullable: false),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
CustomerFirstName = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
CustomerLastName = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
CustomerPhone = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
CustomerEmail = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
IsReturningCustomer = table.Column<bool>(type: "bit", nullable: false),
|
||||
JobDescription = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
HowDidYouHearAboutUs = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
AgreedToTerms = table.Column<bool>(type: "bit", nullable: false),
|
||||
AgreedToTermsAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
SmsOptIn = table.Column<bool>(type: "bit", nullable: false),
|
||||
SignatureDataBase64 = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
LinkedCustomerId = table.Column<int>(type: "int", nullable: true),
|
||||
LinkedJobId = table.Column<int>(type: "int", nullable: true),
|
||||
SubmittedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
ExpiresAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
RemoteLinkEmail = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
RemoteLinkSentAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_KioskSessions", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_KioskSessions_Customers_LinkedCustomerId",
|
||||
column: x => x.LinkedCustomerId,
|
||||
principalTable: "Customers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_KioskSessions_Jobs_LinkedJobId",
|
||||
column: x => x.LinkedJobId,
|
||||
principalTable: "Jobs",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8207));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8213));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8215));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_KioskSessions_LinkedCustomerId",
|
||||
table: "KioskSessions",
|
||||
column: "LinkedCustomerId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_KioskSessions_LinkedJobId",
|
||||
table: "KioskSessions",
|
||||
column: "LinkedJobId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_KioskSessions_SessionToken",
|
||||
table: "KioskSessions",
|
||||
column: "SessionToken",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "KioskSessions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "KioskActivationToken",
|
||||
table: "Companies");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5641));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5655));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5656));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10735
File diff suppressed because it is too large
Load Diff
+71
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddInvoicePublicViewToken : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PublicViewToken",
|
||||
table: "Invoices",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4259));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4264));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4266));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PublicViewToken",
|
||||
table: "Invoices");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8207));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8213));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8215));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1812,6 +1812,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("KioskActivationToken")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("LogoContentType")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -3919,6 +3922,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("PreparedById")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("PublicViewToken")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("SalesTaxAccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -5564,6 +5570,115 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.ToTable("JournalEntryLines");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.KioskSession", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<bool>("AgreedToTerms")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("AgreedToTermsAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("CustomerEmail")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("CustomerFirstName")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("CustomerLastName")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("CustomerPhone")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeletedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("HowDidYouHearAboutUs")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsReturningCustomer")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("JobDescription")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("LinkedCustomerId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int?>("LinkedJobId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("RemoteLinkEmail")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("RemoteLinkSentAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid>("SessionToken")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int>("SessionType")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("SignatureDataBase64")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("SmsOptIn")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("SubmittedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LinkedCustomerId");
|
||||
|
||||
b.HasIndex("LinkedJobId");
|
||||
|
||||
b.HasIndex("SessionToken")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("KioskSessions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.MaintenanceRecord", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -6577,7 +6692,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5641),
|
||||
CreatedAt = new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4259),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -6588,7 +6703,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5655),
|
||||
CreatedAt = new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4264),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -6599,7 +6714,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5656),
|
||||
CreatedAt = new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4266),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
@@ -9721,6 +9836,23 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Navigation("JournalEntry");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.KioskSession", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.Customer", "LinkedCustomer")
|
||||
.WithMany()
|
||||
.HasForeignKey("LinkedCustomerId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("PowderCoating.Core.Entities.Job", "LinkedJob")
|
||||
.WithMany()
|
||||
.HasForeignKey("LinkedJobId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("LinkedCustomer");
|
||||
|
||||
b.Navigation("LinkedJob");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.MaintenanceRecord", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "AssignedUser")
|
||||
|
||||
@@ -121,6 +121,9 @@ public class UnitOfWork : IUnitOfWork
|
||||
private IRepository<GiftCertificate>? _giftCertificates;
|
||||
private IRepository<GiftCertificateRedemption>? _giftCertificateRedemptions;
|
||||
|
||||
// Customer Intake Kiosk
|
||||
private IRepository<KioskSession>? _kioskSessions;
|
||||
|
||||
// Purchase Orders
|
||||
private IPurchaseOrderRepository? _purchaseOrders;
|
||||
private IRepository<PurchaseOrderItem>? _purchaseOrderItems;
|
||||
@@ -460,6 +463,10 @@ public class UnitOfWork : IUnitOfWork
|
||||
public IRepository<GiftCertificateRedemption> GiftCertificateRedemptions =>
|
||||
_giftCertificateRedemptions ??= new Repository<GiftCertificateRedemption>(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="KioskSession"/> customer self-service intake sessions; tenant-filtered with soft delete.</summary>
|
||||
public IRepository<KioskSession> KioskSessions =>
|
||||
_kioskSessions ??= new Repository<KioskSession>(_context);
|
||||
|
||||
// Job Templates
|
||||
/// <summary>Repository for <see cref="JobTemplate"/> reusable job blueprints; tenant-filtered with soft delete.</summary>
|
||||
public IJobTemplateRepository JobTemplates =>
|
||||
|
||||
@@ -621,7 +621,7 @@ public class NotificationService : INotificationService
|
||||
/// (the <paramref name="paymentUrl"/> parameter). Without a payment URL the email is a
|
||||
/// standard "here is your invoice" message with no payment CTA.
|
||||
/// </summary>
|
||||
public async Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null)
|
||||
public async Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null, bool sendSms = false, string? viewUrl = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -705,6 +705,50 @@ public class NotificationService : INotificationService
|
||||
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.InvoiceSent,
|
||||
customerName, string.Join(", ", invoiceEmails), invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
|
||||
}
|
||||
|
||||
// SMS — only when explicitly requested by staff (sendSms=true), customer has opted in,
|
||||
// and the company's SMS is active. Uses viewUrl (permanent) so customer can see the full
|
||||
// invoice; paymentUrl (expiring Stripe link) is surfaced on the view page itself.
|
||||
if (sendSms)
|
||||
{
|
||||
var smsAllowed = await IsSmsAllowedForCompanyAsync(company);
|
||||
var smsPhone = customer.MobilePhone ?? customer.Phone;
|
||||
if (smsAllowed && customer.NotifyBySms && !string.IsNullOrWhiteSpace(smsPhone))
|
||||
{
|
||||
var urlForSms = viewUrl ?? paymentUrl ?? string.Empty;
|
||||
var values = new Dictionary<string, string>
|
||||
{
|
||||
["companyName"] = companyName,
|
||||
["invoiceNumber"] = invoice.InvoiceNumber,
|
||||
["invoiceTotal"] = invoice.Total.ToString("C"),
|
||||
["viewUrl"] = urlForSms
|
||||
};
|
||||
|
||||
var message = await GetRenderedSmsAsync(invoice.CompanyId, NotificationType.InvoiceSent, values,
|
||||
$"{companyName}: Invoice {invoice.InvoiceNumber} for {invoice.Total:C} is ready. View your invoice: {urlForSms} Reply STOP to opt out.");
|
||||
var (smsSent, smsError) = await _smsService.SendSmsAsync(smsPhone, message);
|
||||
|
||||
await WriteLog(new NotificationLog
|
||||
{
|
||||
Channel = NotificationChannel.Sms,
|
||||
NotificationType = NotificationType.InvoiceSent,
|
||||
Status = smsSent ? NotificationStatus.Sent : NotificationStatus.Failed,
|
||||
RecipientName = customerName,
|
||||
Recipient = smsPhone,
|
||||
Message = message,
|
||||
ErrorMessage = smsError,
|
||||
SentAt = DateTime.UtcNow,
|
||||
CustomerId = customer.Id,
|
||||
InvoiceId = invoice.Id,
|
||||
CompanyId = invoice.CompanyId
|
||||
});
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(smsPhone))
|
||||
{
|
||||
await WriteLog(SkippedLog(NotificationChannel.Sms, NotificationType.InvoiceSent,
|
||||
customerName, smsPhone, invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1153,6 +1197,10 @@ public class NotificationService : INotificationService
|
||||
"Invoice {{invoiceNumber}} from {{companyName}}",
|
||||
"<p>Dear {{customerName}},</p><p>Please find your invoice <strong>{{invoiceNumber}}</strong> for <strong>{{invoiceTotal}}</strong> attached.{{invoiceDueDate}}</p><p>Thank you for your business with {{companyName}}.</p>"
|
||||
),
|
||||
[(NotificationType.InvoiceSent, NotificationChannel.Sms)] = (
|
||||
null,
|
||||
"{{companyName}}: Invoice {{invoiceNumber}} for {{invoiceTotal}} is ready. View your invoice: {{viewUrl}} Reply STOP to opt out."
|
||||
),
|
||||
[(NotificationType.PaymentReceived, NotificationChannel.Email)] = (
|
||||
"Payment Received — Invoice {{invoiceNumber}}",
|
||||
"<p>Dear {{customerName}},</p><p>We have received your payment of <strong>{{paymentAmount}}</strong> on {{paymentDate}} for invoice <strong>{{invoiceNumber}}</strong>.{{balanceDue}}</p><p>Thank you for your business with {{companyName}}.</p>"
|
||||
|
||||
@@ -2685,6 +2685,7 @@ public class CompanySettingsController : Controller
|
||||
{
|
||||
list.Add(("{{invoiceTotal}}", "Invoice total amount (formatted as currency)"));
|
||||
list.Add(("{{invoiceDueDate}}", "Due date phrase, e.g. \" Due by January 1, 2026.\" — blank if no due date is set"));
|
||||
list.Add(("{{viewUrl}}", "Permanent link for the customer to view the invoice online (used in SMS)"));
|
||||
}
|
||||
|
||||
if (type == NotificationType.PaymentReceived)
|
||||
|
||||
@@ -1003,11 +1003,18 @@ public class InvoicesController : Controller
|
||||
try
|
||||
{
|
||||
var currentUserForPdf = await _userManager.GetUserAsync(User);
|
||||
if (string.IsNullOrEmpty(invoice.PublicViewToken))
|
||||
{
|
||||
invoice.PublicViewToken = Guid.NewGuid().ToString("N");
|
||||
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
var pdfBytes = await BuildInvoicePdfAsync(invoice, invoice.CompanyId);
|
||||
string? paymentUrl = null;
|
||||
if (!string.IsNullOrEmpty(invoice.PaymentLinkToken))
|
||||
paymentUrl = $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}";
|
||||
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf", paymentUrl);
|
||||
var viewUrl = $"{Request.Scheme}://{Request.Host}/invoice/{invoice.PublicViewToken}";
|
||||
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf", paymentUrl, viewUrl: viewUrl);
|
||||
var notifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id);
|
||||
this.SetNotificationResultToast(notifLog);
|
||||
}
|
||||
@@ -1033,13 +1040,13 @@ public class InvoicesController : Controller
|
||||
// -----------------------------------------------------------------------
|
||||
/// <summary>
|
||||
/// Marks a Draft invoice as Sent, optionally generates a Stripe online-payment link, and
|
||||
/// fires the customer notification with a PDF attachment. Notification failure is caught
|
||||
/// separately and logged as a warning — a failed email must not roll back the status change.
|
||||
/// The payment URL is assembled from the generated token and the current request host so it
|
||||
/// works identically in dev (localhost) and production without config changes.
|
||||
/// fires the customer notification. Staff can choose email, SMS, or both via the modal.
|
||||
/// PublicViewToken is always generated (permanent view link for SMS); PaymentLinkToken is
|
||||
/// only generated when Stripe Connect is active (expiring pay link for email/view page).
|
||||
/// Notification failure is caught separately — a failed send must not roll back the status change.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Send(int id, string? overrideEmail = null)
|
||||
public async Task<IActionResult> Send(int id, string? overrideEmail = null, bool sendEmail = true, bool sendSms = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -1058,27 +1065,39 @@ public class InvoicesController : Controller
|
||||
invoice.UpdatedAt = DateTime.UtcNow;
|
||||
invoice.UpdatedBy = currentUser?.Email;
|
||||
|
||||
// Permanent view token — always generate so SMS always has a link
|
||||
if (string.IsNullOrEmpty(invoice.PublicViewToken))
|
||||
invoice.PublicViewToken = Guid.NewGuid().ToString("N");
|
||||
|
||||
await TryGeneratePaymentTokenAsync(invoice);
|
||||
|
||||
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Generate PDF and send notification
|
||||
string? paymentUrl = null;
|
||||
if (!string.IsNullOrEmpty(invoice.PaymentLinkToken))
|
||||
paymentUrl = $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}";
|
||||
|
||||
bool pdfAndNotifSucceeded = false;
|
||||
var viewUrl = $"{Request.Scheme}://{Request.Host}/invoice/{invoice.PublicViewToken}";
|
||||
|
||||
bool notifSucceeded = false;
|
||||
try
|
||||
{
|
||||
var pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser!.CompanyId);
|
||||
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf", paymentUrl, overrideEmail: overrideEmail?.Trim());
|
||||
pdfAndNotifSucceeded = true;
|
||||
byte[]? pdfBytes = null;
|
||||
if (sendEmail)
|
||||
pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser!.CompanyId);
|
||||
|
||||
await _notificationService.NotifyInvoiceSentAsync(
|
||||
invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf",
|
||||
paymentUrl, overrideEmail: overrideEmail?.Trim(),
|
||||
sendSms: sendSms, viewUrl: viewUrl);
|
||||
|
||||
notifSucceeded = true;
|
||||
}
|
||||
catch (Exception notifyEx)
|
||||
{
|
||||
_logger.LogError(notifyEx,
|
||||
"Invoice {InvoiceId} ({InvoiceNumber}): PDF generation or email dispatch failed. " +
|
||||
"Invoice {InvoiceId} ({InvoiceNumber}): notification failed. " +
|
||||
"Inner: {InnerMessage}. Invoice status was already saved as Sent.",
|
||||
id, invoice.InvoiceNumber, notifyEx.InnerException?.Message ?? "none");
|
||||
}
|
||||
@@ -1087,8 +1106,8 @@ public class InvoicesController : Controller
|
||||
this.SetNotificationResultToast(notifLog);
|
||||
|
||||
TempData["Success"] = $"Invoice {invoice.InvoiceNumber} marked as sent.";
|
||||
if (!pdfAndNotifSucceeded)
|
||||
TempData["WarningPermanent"] = "The invoice is marked as sent, but PDF generation or the customer email failed. Check the notification logs or your email configuration.";
|
||||
if (!notifSucceeded)
|
||||
TempData["WarningPermanent"] = "The invoice is marked as sent, but the notification failed. Check the notification logs or your configuration.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -0,0 +1,667 @@
|
||||
using AutoMapper;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PowderCoating.Application.DTOs.Kiosk;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Application.Services;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using PowderCoating.Web.Hubs;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Handles the customer self-service intake kiosk — both the in-person tablet flow
|
||||
/// (SignalR-triggered, activation-cookie-authenticated) and the remote email-link flow.
|
||||
///
|
||||
/// Anonymous intake routes use ignoreQueryFilters:true to load KioskSession by token
|
||||
/// because the anonymous HTTP context has no CompanyId claim, so the global tenant
|
||||
/// filter would return nothing without that flag.
|
||||
///
|
||||
/// When creating new Customer or Job records from the kiosk, CompanyId is set explicitly
|
||||
/// from session.CompanyId so the EF SaveChanges interceptor doesn't override it with 0.
|
||||
/// </summary>
|
||||
public class KioskController : Controller
|
||||
{
|
||||
private const string CookieName = "KioskDevice";
|
||||
private const int InPersonExpireHours = 2;
|
||||
private const int RemoteExpireHours = 48;
|
||||
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IMapper _mapper;
|
||||
private readonly ILookupCacheService _lookupCache;
|
||||
private readonly IInAppNotificationService _inApp;
|
||||
private readonly IEmailService _emailService;
|
||||
private readonly IHubContext<KioskHub> _kioskHub;
|
||||
private readonly ILogger<KioskController> _logger;
|
||||
|
||||
/// <summary>Initialises all dependencies for the kiosk controller.</summary>
|
||||
public KioskController(
|
||||
IUnitOfWork unitOfWork,
|
||||
IMapper mapper,
|
||||
ILookupCacheService lookupCache,
|
||||
IInAppNotificationService inApp,
|
||||
IEmailService emailService,
|
||||
IHubContext<KioskHub> kioskHub,
|
||||
ILogger<KioskController> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
_lookupCache = lookupCache;
|
||||
_inApp = inApp;
|
||||
_emailService = emailService;
|
||||
_kioskHub = kioskHub;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// WELCOME SCREEN (in-person tablet idle screen)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Idle branded screen displayed on the front-desk tablet.
|
||||
/// Validates the KioskDevice cookie; returns 403 if missing or token mismatch.
|
||||
/// The view connects to KioskHub and listens for StartIntake events.
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Welcome()
|
||||
{
|
||||
var cookie = ReadKioskCookie();
|
||||
if (cookie == null)
|
||||
return View("KioskError", "This device is not activated as a kiosk. Ask a staff member to activate it at Settings → Kiosk.");
|
||||
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(cookie.Value.companyId, ignoreQueryFilters: true);
|
||||
if (company == null || company.KioskActivationToken != cookie.Value.token)
|
||||
return View("KioskError", "Kiosk activation token is invalid or has been revoked. Ask a staff member to re-activate this device.");
|
||||
|
||||
await PopulateKioskViewBag(company);
|
||||
ViewBag.ShowInactivityTimer = false; // Welcome screen stays on indefinitely
|
||||
return View();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DEVICE ACTIVATION (CompanyAdmin-only)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>Shows the kiosk activation page with the current activation status.</summary>
|
||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||
public async Task<IActionResult> Activate()
|
||||
{
|
||||
var companyId = GetCurrentCompanyId();
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
|
||||
ViewBag.IsActivated = !string.IsNullOrEmpty(company?.KioskActivationToken);
|
||||
return View();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a new activation token, saves it to the Company record,
|
||||
/// and writes the KioskDevice cookie so the current browser session becomes the active tablet.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||
public async Task<IActionResult> Activate(string action)
|
||||
{
|
||||
var companyId = GetCurrentCompanyId();
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
|
||||
if (company == null) return NotFound();
|
||||
|
||||
if (action == "deactivate")
|
||||
{
|
||||
company.KioskActivationToken = null;
|
||||
DeleteKioskCookie();
|
||||
TempData["Success"] = "Kiosk deactivated. The tablet will no longer accept intake sessions.";
|
||||
}
|
||||
else
|
||||
{
|
||||
var token = Guid.NewGuid().ToString("N");
|
||||
company.KioskActivationToken = token;
|
||||
WriteKioskCookie(companyId, token);
|
||||
TempData["Success"] = "Kiosk activated. Open /Kiosk/Welcome on the tablet and bookmark it.";
|
||||
}
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return RedirectToAction(nameof(Activate));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// START IN-PERSON SESSION (any authenticated staff member)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Creates an InPerson KioskSession and pushes a SignalR StartIntake event
|
||||
/// to all connections in the company's kiosk group so the tablet navigates automatically.
|
||||
/// Called via fetch from the Dashboard "Start Intake" button.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> StartSession()
|
||||
{
|
||||
var companyId = GetCurrentCompanyId();
|
||||
|
||||
var session = new KioskSession
|
||||
{
|
||||
SessionType = KioskSessionType.InPerson,
|
||||
ExpiresAt = DateTime.UtcNow.AddHours(InPersonExpireHours),
|
||||
CompanyId = companyId
|
||||
};
|
||||
|
||||
await _unitOfWork.KioskSessions.AddAsync(session);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
await _kioskHub.Clients
|
||||
.Group($"kiosk-{companyId}")
|
||||
.SendAsync("StartIntake", session.SessionToken.ToString());
|
||||
|
||||
return Json(new { success = true, sessionToken = session.SessionToken });
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// SEND REMOTE LINK (any authenticated staff member)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>Form for staff to enter a customer's email address and send an intake link.</summary>
|
||||
[Authorize]
|
||||
public IActionResult SendRemoteLink() => View(new SendRemoteLinkDto());
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Remote KioskSession, sends the intake link by email, and redirects back
|
||||
/// with a success message. The link contains the session token (GUID) — not guessable.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> SendRemoteLink(SendRemoteLinkDto dto)
|
||||
{
|
||||
if (!ModelState.IsValid) return View(dto);
|
||||
|
||||
var companyId = GetCurrentCompanyId();
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
|
||||
|
||||
var session = new KioskSession
|
||||
{
|
||||
SessionType = KioskSessionType.Remote,
|
||||
ExpiresAt = DateTime.UtcNow.AddHours(RemoteExpireHours),
|
||||
RemoteLinkEmail = dto.Email,
|
||||
RemoteLinkSentAt = DateTime.UtcNow,
|
||||
CompanyId = companyId
|
||||
};
|
||||
|
||||
await _unitOfWork.KioskSessions.AddAsync(session);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
var link = $"{Request.Scheme}://{Request.Host}/Kiosk/Intake/{session.SessionToken}/Contact";
|
||||
var recipientName = string.IsNullOrWhiteSpace(dto.CustomerName) ? "Valued Customer" : dto.CustomerName;
|
||||
var companyName = company?.CompanyName ?? "Us";
|
||||
|
||||
var html = $@"
|
||||
<div style='font-family:sans-serif;max-width:560px;margin:0 auto;padding:2rem;'>
|
||||
<h2 style='color:#1e293b;'>Hi {System.Web.HttpUtility.HtmlEncode(recipientName)},</h2>
|
||||
<p style='color:#475569;font-size:1rem;'>
|
||||
{System.Web.HttpUtility.HtmlEncode(companyName)} has sent you a quick intake form to fill out before your visit.
|
||||
It only takes a couple of minutes.
|
||||
</p>
|
||||
<a href='{link}' style='display:inline-block;margin:1.5rem 0;padding:1rem 2rem;background:#2563eb;
|
||||
color:#fff;font-weight:600;border-radius:8px;text-decoration:none;font-size:1.1rem;'>
|
||||
Start My Intake Form
|
||||
</a>
|
||||
<p style='color:#94a3b8;font-size:0.85rem;'>
|
||||
This link expires in 48 hours. If you did not expect this email, you can ignore it.
|
||||
</p>
|
||||
</div>";
|
||||
|
||||
await _emailService.SendEmailAsync(
|
||||
dto.Email, recipientName,
|
||||
$"Your intake form from {companyName}",
|
||||
$"Please visit this link to complete your intake form: {link}",
|
||||
htmlBody: html);
|
||||
|
||||
TempData["Success"] = $"Intake link sent to {dto.Email}.";
|
||||
return RedirectToAction(nameof(SendRemoteLink));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// INTAKE STEPS (anonymous — both InPerson and Remote)
|
||||
// =========================================================================
|
||||
|
||||
// ── Step 1: Contact Info ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Displays the contact-info form for the given session token.</summary>
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Contact(Guid token)
|
||||
{
|
||||
var session = await LoadSessionAsync(token);
|
||||
if (session == null) return View("KioskError", "This intake session could not be found. Please ask a staff member to start a new one.");
|
||||
if (!await ValidateSessionState(session)) return RedirectToAction(nameof(Confirmation), new { token });
|
||||
|
||||
await PopulateKioskViewBagFromSession(session);
|
||||
ViewBag.KioskStep = 1;
|
||||
return View(new SubmitKioskContactDto
|
||||
{
|
||||
FirstName = session.CustomerFirstName,
|
||||
LastName = session.CustomerLastName,
|
||||
Phone = session.CustomerPhone,
|
||||
Email = session.CustomerEmail,
|
||||
IsReturningCustomer = session.IsReturningCustomer
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Saves contact info to the session and advances to Step 2.</summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Contact(Guid token, SubmitKioskContactDto dto)
|
||||
{
|
||||
var session = await LoadSessionAsync(token);
|
||||
if (session == null) return View("KioskError", "Session not found.");
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
await PopulateKioskViewBagFromSession(session);
|
||||
ViewBag.KioskStep = 1;
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
session.CustomerFirstName = dto.FirstName.Trim();
|
||||
session.CustomerLastName = dto.LastName.Trim();
|
||||
session.CustomerPhone = dto.Phone.Trim();
|
||||
session.CustomerEmail = dto.Email.Trim().ToLowerInvariant();
|
||||
session.IsReturningCustomer = dto.IsReturningCustomer;
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return RedirectToAction(nameof(Job), new { token });
|
||||
}
|
||||
|
||||
// ── Step 2: Job Description ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>Displays the job-description form.</summary>
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Job(Guid token)
|
||||
{
|
||||
var session = await LoadSessionAsync(token);
|
||||
if (session == null) return View("KioskError", "Session not found.");
|
||||
if (!await ValidateSessionState(session)) return RedirectToAction(nameof(Confirmation), new { token });
|
||||
|
||||
await PopulateKioskViewBagFromSession(session);
|
||||
ViewBag.KioskStep = 2;
|
||||
return View(new SubmitKioskJobDto
|
||||
{
|
||||
JobDescription = session.JobDescription,
|
||||
HowDidYouHearAboutUs = session.HowDidYouHearAboutUs
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Saves the job description and advances to Step 3.</summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Job(Guid token, SubmitKioskJobDto dto)
|
||||
{
|
||||
var session = await LoadSessionAsync(token);
|
||||
if (session == null) return View("KioskError", "Session not found.");
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
await PopulateKioskViewBagFromSession(session);
|
||||
ViewBag.KioskStep = 2;
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
session.JobDescription = dto.JobDescription.Trim();
|
||||
session.HowDidYouHearAboutUs = dto.HowDidYouHearAboutUs?.Trim();
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return RedirectToAction(nameof(Terms), new { token });
|
||||
}
|
||||
|
||||
// ── Step 3: Terms & Consent ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>Displays the terms, SMS opt-in checkbox, and (for InPerson) signature pad.</summary>
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Terms(Guid token)
|
||||
{
|
||||
var session = await LoadSessionAsync(token);
|
||||
if (session == null) return View("KioskError", "Session not found.");
|
||||
if (!await ValidateSessionState(session)) return RedirectToAction(nameof(Confirmation), new { token });
|
||||
|
||||
await PopulateKioskViewBagFromSession(session);
|
||||
ViewBag.KioskStep = 3;
|
||||
ViewBag.IsInPerson = session.SessionType == KioskSessionType.InPerson;
|
||||
return View(new SubmitKioskTermsDto());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves terms agreement, triggers customer/job auto-creation, fires staff notification,
|
||||
/// and redirects to the Confirmation screen.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Terms(Guid token, SubmitKioskTermsDto dto)
|
||||
{
|
||||
var session = await LoadSessionAsync(token);
|
||||
if (session == null) return View("KioskError", "Session not found.");
|
||||
|
||||
// Require signature for in-person sessions
|
||||
if (session.SessionType == KioskSessionType.InPerson &&
|
||||
string.IsNullOrEmpty(dto.SignatureDataBase64))
|
||||
{
|
||||
ModelState.AddModelError("SignatureDataBase64", "Please sign above before continuing.");
|
||||
}
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
await PopulateKioskViewBagFromSession(session);
|
||||
ViewBag.KioskStep = 3;
|
||||
ViewBag.IsInPerson = session.SessionType == KioskSessionType.InPerson;
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
session.AgreedToTerms = true;
|
||||
session.AgreedToTermsAt = DateTime.UtcNow;
|
||||
session.SmsOptIn = dto.SmsOptIn;
|
||||
session.SignatureDataBase64 = dto.SignatureDataBase64;
|
||||
session.Status = KioskSessionStatus.Submitted;
|
||||
session.SubmittedAt = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
await ProcessSubmissionAsync(session);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing kiosk submission for session {SessionToken}", token);
|
||||
// Don't fail the customer-facing page — save what we have and let staff convert manually
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Confirmation), new { token });
|
||||
}
|
||||
|
||||
// ── Confirmation ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Thank-you screen shown after a successful submission.</summary>
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Confirmation(Guid token)
|
||||
{
|
||||
var session = await LoadSessionAsync(token);
|
||||
if (session == null) return View("KioskError", "Session not found.");
|
||||
|
||||
await PopulateKioskViewBagFromSession(session);
|
||||
ViewBag.ShowInactivityTimer = false; // Handled by the countdown JS in the view
|
||||
ViewBag.IsInPerson = session.SessionType == KioskSessionType.InPerson;
|
||||
ViewBag.FirstName = session.CustomerFirstName;
|
||||
return View();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// STAFF REVIEW (authenticated)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Lists all kiosk intake sessions for the current company — submitted, active, and expired.
|
||||
/// Manager or higher access required.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Intakes(string? filter)
|
||||
{
|
||||
var sessions = await _unitOfWork.KioskSessions.GetAllAsync(false,
|
||||
s => s.LinkedCustomer,
|
||||
s => s.LinkedJob);
|
||||
|
||||
var dtos = sessions
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.Select(s => new KioskSessionListDto
|
||||
{
|
||||
Id = s.Id,
|
||||
SessionToken = s.SessionToken,
|
||||
SessionType = s.SessionType,
|
||||
Status = s.Status,
|
||||
CustomerFirstName = s.CustomerFirstName,
|
||||
CustomerLastName = s.CustomerLastName,
|
||||
CustomerEmail = s.CustomerEmail,
|
||||
CustomerPhone = s.CustomerPhone,
|
||||
JobDescription = s.JobDescription,
|
||||
SmsOptIn = s.SmsOptIn,
|
||||
SubmittedAt = s.SubmittedAt,
|
||||
ExpiresAt = s.ExpiresAt,
|
||||
LinkedCustomerId = s.LinkedCustomerId,
|
||||
LinkedJobId = s.LinkedJobId,
|
||||
RemoteLinkEmail = s.RemoteLinkEmail
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Apply filter tab
|
||||
dtos = filter switch
|
||||
{
|
||||
"submitted" => dtos.Where(d => d.Status == KioskSessionStatus.Submitted).ToList(),
|
||||
"active" => dtos.Where(d => d.Status == KioskSessionStatus.Active && !d.IsExpired).ToList(),
|
||||
"expired" => dtos.Where(d => d.IsExpired || d.Status == KioskSessionStatus.Expired).ToList(),
|
||||
_ => dtos
|
||||
};
|
||||
|
||||
ViewBag.ActiveFilter = filter ?? "all";
|
||||
return View(dtos);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// PRIVATE HELPERS
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Loads a KioskSession by SessionToken using ignoreQueryFilters because anonymous requests
|
||||
/// have no CompanyId claim, so the global tenant filter would return nothing without it.
|
||||
/// </summary>
|
||||
private async Task<KioskSession?> LoadSessionAsync(Guid token)
|
||||
{
|
||||
return await _unitOfWork.KioskSessions.FirstOrDefaultAsync(
|
||||
s => s.SessionToken == token && !s.IsDeleted,
|
||||
ignoreQueryFilters: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the session is still in a usable state.
|
||||
/// Returns false (and optionally updates status to Expired) if the session should not proceed.
|
||||
/// </summary>
|
||||
private async Task<bool> ValidateSessionState(KioskSession session)
|
||||
{
|
||||
if (session.Status == KioskSessionStatus.Submitted)
|
||||
return false; // Already done — redirect to Confirmation (idempotent)
|
||||
|
||||
if (session.Status == KioskSessionStatus.Cancelled)
|
||||
return false;
|
||||
|
||||
if (DateTime.UtcNow > session.ExpiresAt && session.Status == KioskSessionStatus.Active)
|
||||
{
|
||||
session.Status = KioskSessionStatus.Expired;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return false;
|
||||
}
|
||||
|
||||
return session.Status == KioskSessionStatus.Active;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core submission logic: matches or creates a Customer, creates a Pending Job,
|
||||
/// applies SMS consent, and fires a staff in-app notification.
|
||||
/// CompanyId is set explicitly on new entities from session.CompanyId so the EF
|
||||
/// SaveChanges interceptor does not override it with 0 (the anonymous tenant context).
|
||||
/// </summary>
|
||||
private async Task ProcessSubmissionAsync(KioskSession session)
|
||||
{
|
||||
var companyId = session.CompanyId;
|
||||
|
||||
// 1. Match or create Customer
|
||||
Customer? customer = null;
|
||||
if (!string.IsNullOrEmpty(session.CustomerEmail))
|
||||
{
|
||||
customer = await _unitOfWork.Customers.FirstOrDefaultAsync(
|
||||
c => c.CompanyId == companyId && c.Email == session.CustomerEmail && !c.IsDeleted,
|
||||
ignoreQueryFilters: true);
|
||||
}
|
||||
|
||||
if (customer == null && !string.IsNullOrEmpty(session.CustomerPhone))
|
||||
{
|
||||
customer = await _unitOfWork.Customers.FirstOrDefaultAsync(
|
||||
c => c.CompanyId == companyId && (c.Phone == session.CustomerPhone || c.MobilePhone == session.CustomerPhone) && !c.IsDeleted,
|
||||
ignoreQueryFilters: true);
|
||||
}
|
||||
|
||||
bool isNewCustomer = customer == null;
|
||||
if (isNewCustomer)
|
||||
{
|
||||
customer = new Customer
|
||||
{
|
||||
CompanyId = companyId,
|
||||
ContactFirstName = session.CustomerFirstName,
|
||||
ContactLastName = session.CustomerLastName,
|
||||
Phone = session.CustomerPhone,
|
||||
Email = session.CustomerEmail,
|
||||
IsActive = true,
|
||||
IsCommercial = false
|
||||
};
|
||||
await _unitOfWork.Customers.AddAsync(customer);
|
||||
await _unitOfWork.CompleteAsync(); // get Customer.Id
|
||||
}
|
||||
|
||||
// 2. Apply SMS consent
|
||||
if (session.SmsOptIn)
|
||||
{
|
||||
customer!.NotifyBySms = true;
|
||||
customer.SmsConsentedAt = session.SubmittedAt ?? DateTime.UtcNow;
|
||||
customer.SmsConsentMethod = session.SessionType == KioskSessionType.InPerson
|
||||
? "KioskIntake"
|
||||
: "RemoteIntake";
|
||||
}
|
||||
|
||||
// 3. Create Job in Pending status
|
||||
var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
|
||||
var pendingStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Pending);
|
||||
|
||||
var jobNumber = await GenerateJobNumberAsync(companyId);
|
||||
var job = new Job
|
||||
{
|
||||
CompanyId = companyId,
|
||||
CustomerId = customer!.Id,
|
||||
JobNumber = jobNumber,
|
||||
JobStatusId = pendingStatus?.Id ?? 1,
|
||||
SpecialInstructions = session.JobDescription,
|
||||
Description = $"Walk-in intake — {session.CustomerFirstName} {session.CustomerLastName}".Trim()
|
||||
};
|
||||
|
||||
await _unitOfWork.Jobs.AddAsync(job);
|
||||
|
||||
// 4. Update session links
|
||||
session.LinkedCustomerId = customer.Id;
|
||||
session.LinkedJobId = job.Id; // will be populated after SaveChanges below
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// job.Id is now set — update session again if needed
|
||||
if (session.LinkedJobId == 0)
|
||||
{
|
||||
session.LinkedJobId = job.Id;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
|
||||
// 5. Fire staff notification
|
||||
var snippet = session.JobDescription.Length > 60 ? session.JobDescription[..60] + "…" : session.JobDescription;
|
||||
var fullName = $"{session.CustomerFirstName} {session.CustomerLastName}".Trim();
|
||||
await _inApp.CreateAsync(
|
||||
companyId,
|
||||
"Walk-in Intake Submitted",
|
||||
$"{fullName} completed their intake form — {snippet}",
|
||||
"KioskIntake",
|
||||
link: $"/Kiosk/Intakes",
|
||||
customerId: customer.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates the next sequential job number using the company's configured prefix.
|
||||
/// Mirrors the logic in JobsController.GenerateJobNumber() — same format: PREFIX-YYMM-####.
|
||||
/// </summary>
|
||||
private async Task<string> GenerateJobNumberAsync(int companyId)
|
||||
{
|
||||
var year = DateTime.Now.Year.ToString()[2..];
|
||||
var month = DateTime.Now.Month.ToString("D2");
|
||||
|
||||
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
|
||||
p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true);
|
||||
|
||||
var jobPrefix = !string.IsNullOrWhiteSpace(prefs?.JobNumberPrefix) ? prefs.JobNumberPrefix : "JOB";
|
||||
var prefix = $"{jobPrefix}-{year}{month}";
|
||||
|
||||
var lastJobNumber = await _unitOfWork.Jobs.GetLastJobNumberByPrefixAsync(companyId, prefix);
|
||||
|
||||
if (lastJobNumber != null)
|
||||
{
|
||||
var lastNumberStr = lastJobNumber[(prefix.Length + 1)..];
|
||||
if (int.TryParse(lastNumberStr, out int lastNumber))
|
||||
return $"{prefix}-{(lastNumber + 1):D4}";
|
||||
}
|
||||
|
||||
return $"{prefix}-0001";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the KioskDevice cookie and parses the "{companyId}:{token}" value.
|
||||
/// Returns null if the cookie is absent or malformed.
|
||||
/// </summary>
|
||||
private (int companyId, string token)? ReadKioskCookie()
|
||||
{
|
||||
if (!Request.Cookies.TryGetValue(CookieName, out var raw) || string.IsNullOrEmpty(raw))
|
||||
return null;
|
||||
|
||||
var parts = raw.Split(':', 2);
|
||||
if (parts.Length != 2 || !int.TryParse(parts[0], out int id))
|
||||
return null;
|
||||
|
||||
return (id, parts[1]);
|
||||
}
|
||||
|
||||
/// <summary>Writes a long-lived HttpOnly kiosk device cookie.</summary>
|
||||
private void WriteKioskCookie(int companyId, string token)
|
||||
{
|
||||
Response.Cookies.Append(CookieName, $"{companyId}:{token}", new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = true,
|
||||
SameSite = SameSiteMode.Lax,
|
||||
MaxAge = TimeSpan.FromDays(365)
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Removes the kiosk device cookie (deactivation).</summary>
|
||||
private void DeleteKioskCookie()
|
||||
{
|
||||
Response.Cookies.Delete(CookieName);
|
||||
}
|
||||
|
||||
/// <summary>Returns the current authenticated user's CompanyId claim.</summary>
|
||||
private int GetCurrentCompanyId()
|
||||
{
|
||||
var claim = User.FindFirst("CompanyId")?.Value;
|
||||
return int.TryParse(claim, out int id) ? id : 0;
|
||||
}
|
||||
|
||||
/// <summary>Sets ViewBag properties needed by _KioskLayout from a Company entity.</summary>
|
||||
private async Task PopulateKioskViewBag(Company company)
|
||||
{
|
||||
ViewBag.CompanyName = company.CompanyName;
|
||||
ViewBag.CompanyLogoUrl = !string.IsNullOrEmpty(company.LogoFilePath)
|
||||
? $"/CompanyLogo/{company.Id}"
|
||||
: null;
|
||||
ViewBag.WelcomeUrl = "/Kiosk/Welcome";
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Loads the company from a session's CompanyId and populates ViewBag.</summary>
|
||||
private async Task PopulateKioskViewBagFromSession(KioskSession session)
|
||||
{
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(session.CompanyId, ignoreQueryFilters: true);
|
||||
if (company != null)
|
||||
await PopulateKioskViewBag(company);
|
||||
|
||||
ViewBag.SessionToken = session.SessionToken;
|
||||
ViewBag.SessionType = session.SessionType;
|
||||
}
|
||||
}
|
||||
@@ -153,6 +153,86 @@ public class PaymentController : Controller
|
||||
return Ok(new { clientSecret, surchargeAmount = surcharge });
|
||||
}
|
||||
|
||||
// ─── GET /invoice/{token} ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Customer-facing read-only invoice view page. Resolved via PublicViewToken (permanent, no expiry).
|
||||
/// Shows full line items, totals, and company branding. If a valid PaymentLinkToken exists, renders
|
||||
/// a "Pay Now" button linking to /pay/{paymentLinkToken}. This is the link sent in SMS messages
|
||||
/// since SMS cannot attach a PDF.
|
||||
/// </summary>
|
||||
[HttpGet("/invoice/{token}")]
|
||||
public async Task<IActionResult> InvoiceView(string token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var invoice = await _context.Invoices
|
||||
.AsNoTracking()
|
||||
.Include(i => i.InvoiceItems)
|
||||
.Include(i => i.Customer)
|
||||
.Include(i => i.Job)
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(i => i.PublicViewToken == token && !i.IsDeleted);
|
||||
|
||||
if (invoice == null)
|
||||
return View("PaymentError", "This invoice link is invalid or has been removed.");
|
||||
|
||||
var company = await _context.Companies.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(c => c.Id == invoice.CompanyId && !c.IsDeleted);
|
||||
|
||||
if (company == null)
|
||||
return View("PaymentError", "Unable to load invoice details.");
|
||||
|
||||
var paymentUrl = (!string.IsNullOrEmpty(invoice.PaymentLinkToken)
|
||||
&& invoice.PaymentLinkExpiresAt > DateTime.UtcNow
|
||||
&& invoice.BalanceDue > 0)
|
||||
? $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}"
|
||||
: null;
|
||||
|
||||
var vm = new InvoiceViewViewModel
|
||||
{
|
||||
InvoiceNumber = invoice.InvoiceNumber,
|
||||
InvoiceDate = invoice.InvoiceDate,
|
||||
DueDate = invoice.DueDate,
|
||||
CustomerName = invoice.Customer != null
|
||||
? $"{invoice.Customer.ContactFirstName} {invoice.Customer.ContactLastName}".Trim()
|
||||
: "Valued Customer",
|
||||
CompanyName = company.CompanyName,
|
||||
CompanyPhone = company.Phone,
|
||||
CompanyAddress = string.Join(", ", new[] { company.Address, company.City, company.State, company.ZipCode }
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))),
|
||||
LogoFilePath = company.LogoFilePath,
|
||||
SubTotal = invoice.SubTotal,
|
||||
TaxPercent = invoice.TaxPercent,
|
||||
TaxAmount = invoice.TaxAmount,
|
||||
DiscountAmount = invoice.DiscountAmount,
|
||||
Total = invoice.Total,
|
||||
AmountPaid = invoice.AmountPaid,
|
||||
BalanceDue = invoice.BalanceDue,
|
||||
Status = invoice.Status,
|
||||
Notes = invoice.Notes,
|
||||
Terms = invoice.Terms,
|
||||
JobNumber = invoice.Job?.JobNumber,
|
||||
PaymentUrl = paymentUrl,
|
||||
LineItems = invoice.InvoiceItems.Select(i => new InvoiceViewLineItem
|
||||
{
|
||||
Description = i.Description,
|
||||
Quantity = i.Quantity,
|
||||
UnitPrice = i.UnitPrice,
|
||||
TotalPrice = i.TotalPrice
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
return View(vm);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "InvoiceView failed for token {Token}", token);
|
||||
return View("PaymentError", "An error occurred loading this invoice.");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── GET /pay/deposit/{token} ────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
@@ -897,6 +977,39 @@ public class DepositPaymentPageViewModel
|
||||
public string StripeAccountId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class InvoiceViewViewModel
|
||||
{
|
||||
public string InvoiceNumber { get; set; } = string.Empty;
|
||||
public DateTime InvoiceDate { get; set; }
|
||||
public DateTime? DueDate { get; set; }
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public string? CompanyPhone { get; set; }
|
||||
public string? CompanyAddress { get; set; }
|
||||
public string? LogoFilePath { get; set; }
|
||||
public decimal SubTotal { get; set; }
|
||||
public decimal TaxPercent { get; set; }
|
||||
public decimal TaxAmount { get; set; }
|
||||
public decimal DiscountAmount { get; set; }
|
||||
public decimal Total { get; set; }
|
||||
public decimal AmountPaid { get; set; }
|
||||
public decimal BalanceDue { get; set; }
|
||||
public InvoiceStatus Status { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public string? Terms { get; set; }
|
||||
public string? JobNumber { get; set; }
|
||||
public string? PaymentUrl { get; set; }
|
||||
public List<InvoiceViewLineItem> LineItems { get; set; } = new();
|
||||
}
|
||||
|
||||
public class InvoiceViewLineItem
|
||||
{
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public decimal Quantity { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
public decimal TotalPrice { get; set; }
|
||||
}
|
||||
|
||||
public class CreateIntentRequest
|
||||
{
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace PowderCoating.Web.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR hub that delivers "StartIntake" push events to the front-desk tablet.
|
||||
/// Deliberately [AllowAnonymous] — the tablet runs without a logged-in user.
|
||||
/// Security is enforced at the kiosk route level via the KioskActivationToken cookie.
|
||||
///
|
||||
/// On connect the tablet passes ?companyId=N in the hub URL query string; this hub
|
||||
/// places that connection in the company-scoped group "kiosk-{companyId}" so that
|
||||
/// KioskController.StartSession can push to exactly that company's tablet.
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
public class KioskHub : Hub
|
||||
{
|
||||
private readonly ILogger<KioskHub> _logger;
|
||||
|
||||
/// <summary>Initialises the hub with the required logger.</summary>
|
||||
public KioskHub(ILogger<KioskHub> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Joins the connection to the company-scoped kiosk group on connect.
|
||||
/// companyId is read from the ?companyId query param embedded in the hub URL by the Welcome view.
|
||||
/// </summary>
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var companyId = Context.GetHttpContext()?.Request.Query["companyId"].FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(companyId))
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, $"kiosk-{companyId}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in KioskHub.OnConnectedAsync for connection {ConnectionId}", Context.ConnectionId);
|
||||
}
|
||||
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
|
||||
/// <summary>Logs unexpected disconnects (e.g. tablet going to sleep).</summary>
|
||||
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
if (exception != null)
|
||||
_logger.LogWarning(exception, "KioskHub client disconnected with error: {ConnectionId}", Context.ConnectionId);
|
||||
|
||||
await base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
}
|
||||
@@ -736,6 +736,7 @@ app.MapRazorPages();
|
||||
// Map SignalR hubs
|
||||
app.MapHub<PowderCoating.Web.Hubs.NotificationHub>("/hubs/notifications");
|
||||
app.MapHub<PowderCoating.Web.Hubs.ShopHub>("/hubs/shop");
|
||||
app.MapHub<PowderCoating.Web.Hubs.KioskHub>("/hubs/kiosk");
|
||||
|
||||
app.MapHealthChecks("/health");
|
||||
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
var canResend = !isDraft && !isVoided && Model.Status != InvoiceStatus.Paid;
|
||||
var hasEmail = !string.IsNullOrWhiteSpace(Model.CustomerEmail);
|
||||
var emailOptedOut = hasEmail && !Model.CustomerNotifyByEmail;
|
||||
var smsPhone = !string.IsNullOrWhiteSpace(Model.CustomerMobilePhone) ? Model.CustomerMobilePhone : Model.CustomerPhone;
|
||||
var hasSms = !string.IsNullOrWhiteSpace(smsPhone) && Model.CustomerNotifyBySms;
|
||||
var showSendModal = hasEmail && !emailOptedOut && hasSms; // both channels — show choice modal
|
||||
var directSendSms = !hasEmail && hasSms; // SMS only — skip modal
|
||||
var hasAvailableCredits = ViewBag.AvailableCreditMemos != null && ((IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.AvailableCreditMemos).Any();
|
||||
var canIssueRefund = !isDraft && !isVoided && Model.AmountPaid > 0;
|
||||
var canApplyCredit = !isVoided && Model.BalanceDue > 0 && hasAvailableCredits;
|
||||
@@ -579,14 +583,32 @@
|
||||
<form id="sendInvoiceForm" asp-action="Send" asp-route-id="@Model.Id" method="post">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="overrideEmail" id="sendInvoiceOverrideEmail" value="" />
|
||||
@if (emailOptedOut)
|
||||
<input type="hidden" name="sendEmail" id="sendInvoiceSendEmail" value="true" />
|
||||
<input type="hidden" name="sendSms" id="sendInvoiceSendSms" value="false" />
|
||||
@if (emailOptedOut && !hasSms)
|
||||
{
|
||||
<button type="button" class="btn btn-primary w-100" disabled
|
||||
title="Email notifications are turned off for this customer">
|
||||
title="No delivery channel available for this customer">
|
||||
<i class="bi bi-send me-2"></i>Send Invoice
|
||||
</button>
|
||||
}
|
||||
else if (hasEmail)
|
||||
else if (showSendModal)
|
||||
{
|
||||
@* Both email + SMS available — let staff choose *@
|
||||
<button type="button" class="btn btn-primary w-100"
|
||||
data-bs-toggle="modal" data-bs-target="#sendChannelModal">
|
||||
<i class="bi bi-send me-2"></i>Send Invoice
|
||||
</button>
|
||||
}
|
||||
else if (directSendSms)
|
||||
{
|
||||
@* SMS only — send directly *@
|
||||
<button type="button" class="btn btn-primary w-100"
|
||||
onclick="submitSendInvoice(false, true)">
|
||||
<i class="bi bi-send me-2"></i>Send Invoice via SMS
|
||||
</button>
|
||||
}
|
||||
else if (hasEmail && !emailOptedOut)
|
||||
{
|
||||
<button type="button" class="btn btn-primary w-100"
|
||||
data-bs-toggle="modal" data-bs-target="#sendInvoiceModal">
|
||||
@@ -839,13 +861,50 @@
|
||||
</div>
|
||||
<div class="modal-footer border-0 pt-0">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="document.getElementById('sendInvoiceForm').submit()">
|
||||
<button type="button" class="btn btn-primary" onclick="submitSendInvoice(true, false)">
|
||||
<i class="bi bi-send me-1"></i>Yes, Send Invoice
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (showSendModal)
|
||||
{
|
||||
<!-- Send Channel Choice Modal (shown when customer has both email + SMS) -->
|
||||
<div class="modal fade" id="sendChannelModal" tabindex="-1" aria-labelledby="sendChannelModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header border-0 pb-0">
|
||||
<h5 class="modal-title" id="sendChannelModalLabel">
|
||||
<i class="bi bi-send text-primary me-2"></i>Send Invoice
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body pt-2">
|
||||
<p class="mb-3">How would you like to send <strong>@Model.InvoiceNumber</strong> to <strong>@Model.CustomerName</strong>?</p>
|
||||
<div class="d-grid gap-2">
|
||||
<button type="button" class="btn btn-outline-primary text-start" onclick="submitSendInvoice(true, false)" data-bs-dismiss="modal">
|
||||
<i class="bi bi-envelope me-2"></i>Email only
|
||||
<small class="d-block text-muted ms-4">PDF attached · @Model.CustomerEmail</small>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-primary text-start" onclick="submitSendInvoice(false, true)" data-bs-dismiss="modal">
|
||||
<i class="bi bi-phone me-2"></i>SMS only
|
||||
<small class="d-block text-muted ms-4">View link · @smsPhone</small>
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary text-start" onclick="submitSendInvoice(true, true)" data-bs-dismiss="modal">
|
||||
<i class="bi bi-send me-2"></i>Both Email & SMS
|
||||
<small class="d-block text-muted ms-4">PDF via email + view link via SMS</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-0 pt-0">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (canPay)
|
||||
@@ -1381,6 +1440,12 @@
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
function submitSendInvoice(sendEmail, sendSms) {
|
||||
document.getElementById('sendInvoiceSendEmail').value = sendEmail ? 'true' : 'false';
|
||||
document.getElementById('sendInvoiceSendSms').value = sendSms ? 'true' : 'false';
|
||||
document.getElementById('sendInvoiceForm').submit();
|
||||
}
|
||||
|
||||
function openEditPaymentModal(paymentId, invoiceId, paymentDate, paymentMethod, reference, notes, depositAccountId) {
|
||||
document.getElementById('editPaymentId').value = paymentId;
|
||||
document.getElementById('editPaymentDate').value = paymentDate;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
@{
|
||||
ViewData["Title"] = "Kiosk Setup";
|
||||
bool isActivated = ViewBag.IsActivated as bool? ?? false;
|
||||
}
|
||||
|
||||
<div class="container-fluid px-4">
|
||||
<div class="d-flex align-items-center gap-3 mb-4">
|
||||
<i class="bi bi-tablet fs-3 text-primary"></i>
|
||||
<div>
|
||||
<h1 class="h3 fw-bold mb-0">Kiosk Setup</h1>
|
||||
<p class="text-muted mb-0">Configure the front-desk intake tablet</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (TempData["Success"] != null)
|
||||
{
|
||||
<div class="alert alert-success alert-permanent mb-4">
|
||||
<i class="bi bi-check-circle me-2"></i> @TempData["Success"]
|
||||
</div>
|
||||
}
|
||||
@if (TempData["Error"] != null)
|
||||
{
|
||||
<div class="alert alert-danger alert-permanent mb-4">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i> @TempData["Error"]
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="row g-4">
|
||||
|
||||
@* Status card *@
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title fw-semibold mb-3">Current Status</h5>
|
||||
@if (isActivated)
|
||||
{
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<span class="badge bg-success fs-6 px-3 py-2">
|
||||
<i class="bi bi-check-circle me-1"></i> Active
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-muted">
|
||||
A kiosk device is currently activated. The tablet will respond to
|
||||
"Start Intake" commands from your staff.
|
||||
</p>
|
||||
<form method="post" asp-action="Activate">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="action" value="deactivate" />
|
||||
<button type="submit" class="btn btn-outline-danger"
|
||||
onclick="return confirm('Deactivate the kiosk? The tablet will no longer receive intake requests.');">
|
||||
<i class="bi bi-tablet me-1"></i> Deactivate Kiosk
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<span class="badge bg-secondary fs-6 px-3 py-2">
|
||||
<i class="bi bi-dash-circle me-1"></i> Not Activated
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-muted">
|
||||
No kiosk device is activated. Click below to activate this browser
|
||||
session as the kiosk device.
|
||||
</p>
|
||||
<form method="post" asp-action="Activate">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="action" value="activate" />
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-tablet me-1"></i> Activate This Device
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Instructions card *@
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title fw-semibold mb-3">Setup Instructions</h5>
|
||||
<ol class="text-muted" style="line-height:2;">
|
||||
<li>Open this page on the <strong>tablet</strong> and tap <em>Activate This Device</em>.</li>
|
||||
<li>After activation, navigate to <code>/Kiosk/Welcome</code> on the tablet.</li>
|
||||
<li>Bookmark that page so it survives a browser restart.</li>
|
||||
<li>Keep the tablet browser open — SignalR maintains a live connection.</li>
|
||||
<li>Use <em>Start Customer Intake</em> on the Dashboard or Jobs list to push a session to the tablet.</li>
|
||||
</ol>
|
||||
<div class="alert alert-info mb-0">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
Only one device can be active at a time. Re-activating replaces the previous device token.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,53 @@
|
||||
@{
|
||||
Layout = "~/Views/Shared/_KioskLayout.cshtml";
|
||||
ViewData["Title"] = "Thank You";
|
||||
bool isInPerson = ViewBag.IsInPerson as bool? ?? false;
|
||||
string firstName = ViewBag.FirstName as string ?? "there";
|
||||
}
|
||||
|
||||
<div class="kiosk-confirmation py-5">
|
||||
<div class="kiosk-confirmation-icon">
|
||||
<i class="bi bi-check-circle-fill"></i>
|
||||
</div>
|
||||
|
||||
<h2 class="fw-bold" style="font-size:2rem;">Thank you, @firstName!</h2>
|
||||
|
||||
@if (isInPerson)
|
||||
{
|
||||
<p class="text-muted mt-2" style="font-size:1.1rem;">
|
||||
A team member will be right with you.
|
||||
</p>
|
||||
<p class="kiosk-countdown" id="countdown-msg">
|
||||
Returning to the welcome screen in <span id="countdown">30</span> seconds…
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted mt-2" style="font-size:1.1rem;">
|
||||
We've received your intake form and will be in touch soon.
|
||||
</p>
|
||||
<p class="text-muted mt-4" style="font-size:0.95rem;">
|
||||
You can close this window.
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (isInPerson)
|
||||
{
|
||||
@section Scripts {
|
||||
<script>
|
||||
(function () {
|
||||
var secs = 30;
|
||||
var el = document.getElementById("countdown");
|
||||
var interval = setInterval(function () {
|
||||
secs--;
|
||||
if (el) el.textContent = secs;
|
||||
if (secs <= 0) {
|
||||
clearInterval(interval);
|
||||
window.location.href = "@ViewBag.WelcomeUrl";
|
||||
}
|
||||
}, 1000);
|
||||
})();
|
||||
</script>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
@model PowderCoating.Application.DTOs.Kiosk.SubmitKioskContactDto
|
||||
@{
|
||||
Layout = "~/Views/Shared/_KioskLayout.cshtml";
|
||||
ViewData["Title"] = "Your Information";
|
||||
var token = ViewBag.SessionToken as Guid? ?? Guid.Empty;
|
||||
}
|
||||
|
||||
<div class="kiosk-card">
|
||||
<h2 class="fw-bold mb-1" style="font-size:1.6rem;">Tell us about yourself</h2>
|
||||
<p class="text-muted mb-4">All fields are required.</p>
|
||||
|
||||
<form method="post" action="/Kiosk/Intake/@token/Contact" id="contactForm">
|
||||
@Html.AntiForgeryToken()
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-sm-6">
|
||||
<label asp-for="FirstName" class="form-label">First Name</label>
|
||||
<input asp-for="FirstName" class="form-control" autocomplete="given-name"
|
||||
autocapitalize="words" spellcheck="false" placeholder="Jane" />
|
||||
<span asp-validation-for="FirstName" class="text-danger small"></span>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label asp-for="LastName" class="form-label">Last Name</label>
|
||||
<input asp-for="LastName" class="form-control" autocomplete="family-name"
|
||||
autocapitalize="words" spellcheck="false" placeholder="Smith" />
|
||||
<span asp-validation-for="LastName" class="text-danger small"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<label asp-for="Phone" class="form-label">Phone Number</label>
|
||||
<input asp-for="Phone" class="form-control" type="tel" inputmode="tel"
|
||||
autocomplete="tel" placeholder="(555) 555-0100" />
|
||||
<span asp-validation-for="Phone" class="text-danger small"></span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<label asp-for="Email" class="form-label">Email Address</label>
|
||||
<input asp-for="Email" class="form-control" type="email" inputmode="email"
|
||||
autocomplete="email" placeholder="jane@example.com" />
|
||||
<span asp-validation-for="Email" class="text-danger small"></span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 p-3 rounded-3" style="background:#f1f5f9;">
|
||||
<div class="form-check">
|
||||
<input asp-for="IsReturningCustomer" class="form-check-input" type="checkbox" />
|
||||
<label asp-for="IsReturningCustomer" class="form-check-label">
|
||||
I've been a customer before
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary kiosk-btn">
|
||||
Continue <i class="bi bi-arrow-right ms-2"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,46 @@
|
||||
@model PowderCoating.Application.DTOs.Kiosk.SubmitKioskJobDto
|
||||
@{
|
||||
Layout = "~/Views/Shared/_KioskLayout.cshtml";
|
||||
ViewData["Title"] = "About Your Project";
|
||||
var token = ViewBag.SessionToken as Guid? ?? Guid.Empty;
|
||||
}
|
||||
|
||||
<div class="kiosk-card">
|
||||
<h2 class="fw-bold mb-1" style="font-size:1.6rem;">What brings you in?</h2>
|
||||
<p class="text-muted mb-4">Tell us a little about what you need coated.</p>
|
||||
|
||||
<form method="post" action="/Kiosk/Intake/@token/Job">
|
||||
@Html.AntiForgeryToken()
|
||||
|
||||
<div class="mb-3">
|
||||
<label asp-for="JobDescription" class="form-label">Describe your project</label>
|
||||
<textarea asp-for="JobDescription" class="form-control" rows="5"
|
||||
placeholder="e.g. Motorcycle frame, two-tone black and chrome, remove old coating first..."
|
||||
style="min-height:160px;resize:none;"></textarea>
|
||||
<span asp-validation-for="JobDescription" class="text-danger small"></span>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label asp-for="HowDidYouHearAboutUs" class="form-label">How did you hear about us? <span class="text-muted fw-normal">(optional)</span></label>
|
||||
<select asp-for="HowDidYouHearAboutUs" class="form-select">
|
||||
<option value="">— Select one —</option>
|
||||
<option>Google / Online Search</option>
|
||||
<option>Friend or Family Referral</option>
|
||||
<option>Social Media</option>
|
||||
<option>Drove by the shop</option>
|
||||
<option>Returning Customer</option>
|
||||
<option>Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-3">
|
||||
<a href="/Kiosk/Intake/@token/Contact" class="btn btn-outline-secondary"
|
||||
style="min-height:64px;border-radius:12px;font-size:1.1rem;flex:0 0 auto;padding:0 2rem;">
|
||||
<i class="bi bi-arrow-left me-1"></i> Back
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary kiosk-btn">
|
||||
Continue <i class="bi bi-arrow-right ms-2"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,98 @@
|
||||
@model PowderCoating.Application.DTOs.Kiosk.SubmitKioskTermsDto
|
||||
@{
|
||||
Layout = "~/Views/Shared/_KioskLayout.cshtml";
|
||||
ViewData["Title"] = "Terms & Consent";
|
||||
var token = ViewBag.SessionToken as Guid? ?? Guid.Empty;
|
||||
bool isInPerson = ViewBag.IsInPerson as bool? ?? false;
|
||||
}
|
||||
|
||||
<div class="kiosk-card">
|
||||
<h2 class="fw-bold mb-1" style="font-size:1.6rem;">Terms & Consent</h2>
|
||||
<p class="text-muted mb-4">Please read and agree to the following before we proceed.</p>
|
||||
|
||||
<form method="post" action="/Kiosk/Intake/@token/Terms" id="termsForm">
|
||||
@Html.AntiForgeryToken()
|
||||
|
||||
@* Terms scroll box *@
|
||||
<div class="kiosk-terms-scroll mb-4">
|
||||
<strong>Work Authorization & Liability Waiver</strong>
|
||||
<p class="mt-2">
|
||||
By signing below (or checking the box), you authorize @(ViewBag.CompanyName ?? "this shop")
|
||||
to perform the powder coating services described in your intake form.
|
||||
</p>
|
||||
<p>
|
||||
You acknowledge that you are the owner of the items submitted for coating, or you
|
||||
have authority to authorize work on them. You release the shop from liability for
|
||||
pre-existing damage, hidden defects, or items left unclaimed after 30 days.
|
||||
</p>
|
||||
<p>
|
||||
Final pricing is subject to a formal quote. Work will not begin until you approve
|
||||
the quoted amount. Payment is due upon pickup unless otherwise agreed in writing.
|
||||
</p>
|
||||
<p class="mb-0">
|
||||
You agree to comply with all pickup and payment terms provided by the shop.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@* SMS consent — separate checkbox per plan *@
|
||||
<div class="p-3 rounded-3 mb-3" style="background:#f0f9ff;border:1px solid #bae6fd;">
|
||||
<div class="form-check">
|
||||
<input asp-for="SmsOptIn" class="form-check-input" type="checkbox" />
|
||||
<label asp-for="SmsOptIn" class="form-check-label">
|
||||
I consent to receive SMS text messages with updates about my order.
|
||||
<span class="text-muted d-block mt-1" style="font-size:0.85rem;">
|
||||
Message and data rates may apply. Reply STOP to opt out at any time.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Terms agreement *@
|
||||
<div class="p-3 rounded-3 mb-4" style="background:#f8fafc;border:1px solid #e2e8f0;">
|
||||
<div class="form-check">
|
||||
<input asp-for="AgreedToTerms" class="form-check-input" type="checkbox" required />
|
||||
<label asp-for="AgreedToTerms" class="form-check-label fw-semibold">
|
||||
I have read and agree to the terms above.
|
||||
</label>
|
||||
<span asp-validation-for="AgreedToTerms" class="text-danger d-block small mt-1"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Signature pad — in-person only *@
|
||||
@if (isInPerson)
|
||||
{
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold">Your Signature</label>
|
||||
<canvas id="signatureCanvas"></canvas>
|
||||
<div id="signatureError" class="text-danger small mt-1 d-none">
|
||||
Please sign above before continuing.
|
||||
</div>
|
||||
<input type="hidden" id="SignatureDataBase64" name="SignatureDataBase64" />
|
||||
<button type="button" id="clearSignatureBtn"
|
||||
class="btn btn-sm btn-outline-secondary mt-2">
|
||||
<i class="bi bi-eraser me-1"></i> Clear
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="d-flex gap-3">
|
||||
<a href="/Kiosk/Intake/@token/Job" class="btn btn-outline-secondary"
|
||||
style="min-height:64px;border-radius:12px;font-size:1.1rem;flex:0 0 auto;padding:0 2rem;">
|
||||
<i class="bi bi-arrow-left me-1"></i> Back
|
||||
</a>
|
||||
<button type="submit" class="btn btn-success kiosk-btn">
|
||||
<i class="bi bi-check-circle me-2"></i> Submit
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@if (isInPerson)
|
||||
{
|
||||
@section Scripts {
|
||||
<script src="https://cdn.jsdelivr.net/npm/signature_pad@4.1.7/dist/signature_pad.umd.min.js"
|
||||
integrity="sha384-bQMMRVcRi5vEIBLKnB4FY7tBOA9k/Qvd/9zSWMNO4h0zfB2qLj4DV2R/JyPAbF3"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="~/js/kiosk-terms.js"></script>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
@model List<PowderCoating.Application.DTOs.Kiosk.KioskSessionListDto>
|
||||
@using PowderCoating.Core.Enums
|
||||
@{
|
||||
ViewData["Title"] = "Customer Intakes";
|
||||
string activeFilter = ViewBag.ActiveFilter as string ?? "all";
|
||||
}
|
||||
|
||||
<div class="container-fluid px-4">
|
||||
<div class="d-flex align-items-center justify-content-between mb-4 flex-wrap gap-2">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<i class="bi bi-clipboard-check fs-3 text-primary"></i>
|
||||
<div>
|
||||
<h1 class="h3 fw-bold mb-0">Customer Intakes</h1>
|
||||
<p class="text-muted mb-0">Walk-in and remote intake sessions</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="/Kiosk/SendRemoteLink" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-envelope-at me-1"></i> Send Remote Link
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Filter tabs *@
|
||||
<ul class="nav nav-tabs mb-4">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link @(activeFilter == "all" ? "active" : "")" href="?filter=all">All (@Model.Count)</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link @(activeFilter == "submitted" ? "active" : "")" href="?filter=submitted">
|
||||
Submitted (@Model.Count(d => d.Status == KioskSessionStatus.Submitted))
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link @(activeFilter == "active" ? "active" : "")" href="?filter=active">
|
||||
Pending (@Model.Count(d => d.Status == KioskSessionStatus.Active && !d.IsExpired))
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link @(activeFilter == "expired" ? "active" : "")" href="?filter=expired">
|
||||
Expired (@Model.Count(d => d.IsExpired))
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@if (!Model.Any())
|
||||
{
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="bi bi-inbox fs-1 mb-3 d-block"></i>
|
||||
<p>No intake sessions found.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Customer</th>
|
||||
<th>Contact</th>
|
||||
<th>Project</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
<th>SMS</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var s in Model)
|
||||
{
|
||||
<tr>
|
||||
<td class="text-nowrap text-muted small">
|
||||
@(s.SubmittedAt?.ToLocalTime().ToString("MM/dd/yy h:mm tt") ?? s.ExpiresAt.AddHours(-2).ToLocalTime().ToString("MM/dd/yy h:mm tt"))
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-semibold">@s.CustomerFullName</div>
|
||||
@if (s.LinkedCustomerId.HasValue)
|
||||
{
|
||||
<a href="/Customers/Details/@s.LinkedCustomerId" class="small text-success">
|
||||
<i class="bi bi-person-check me-1"></i>Customer matched
|
||||
</a>
|
||||
}
|
||||
</td>
|
||||
<td class="small text-muted">
|
||||
@if (!string.IsNullOrEmpty(s.CustomerPhone))
|
||||
{
|
||||
<div><i class="bi bi-telephone me-1"></i>@s.CustomerPhone</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(s.CustomerEmail))
|
||||
{
|
||||
<div><i class="bi bi-envelope me-1"></i>@s.CustomerEmail</div>
|
||||
}
|
||||
</td>
|
||||
<td style="max-width:280px;">
|
||||
<span class="text-truncate d-block" style="max-width:260px;"
|
||||
title="@s.JobDescription">@s.JobDescriptionSnippet</span>
|
||||
</td>
|
||||
<td>
|
||||
@if (s.SessionType == KioskSessionType.InPerson)
|
||||
{
|
||||
<span class="badge bg-primary-subtle text-primary">
|
||||
<i class="bi bi-tablet me-1"></i>In-Person
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-purple-subtle text-purple" style="background:#ede9fe;color:#6d28d9;">
|
||||
<i class="bi bi-envelope me-1"></i>Remote
|
||||
</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (s.Status == KioskSessionStatus.Submitted && s.IsConverted)
|
||||
{
|
||||
<span class="badge bg-success">Converted</span>
|
||||
}
|
||||
else if (s.Status == KioskSessionStatus.Submitted)
|
||||
{
|
||||
<span class="badge bg-info text-dark">Submitted</span>
|
||||
}
|
||||
else if (s.Status == KioskSessionStatus.Active && !s.IsExpired)
|
||||
{
|
||||
<span class="badge bg-warning text-dark">In Progress</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary">Expired</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (s.SmsOptIn)
|
||||
{
|
||||
<i class="bi bi-check-circle-fill text-success" title="SMS opt-in"></i>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-dash text-muted"></i>
|
||||
}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
@if (s.LinkedJobId.HasValue)
|
||||
{
|
||||
<a href="/Jobs/Details/@s.LinkedJobId" class="btn btn-sm btn-outline-success me-1">
|
||||
<i class="bi bi-briefcase me-1"></i>View Job
|
||||
</a>
|
||||
}
|
||||
@if (s.LinkedCustomerId.HasValue)
|
||||
{
|
||||
<a href="/Customers/Details/@s.LinkedCustomerId" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-person me-1"></i>Customer
|
||||
</a>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,13 @@
|
||||
@model string
|
||||
@{
|
||||
Layout = "~/Views/Shared/_KioskLayout.cshtml";
|
||||
ViewData["Title"] = "Unable to Start";
|
||||
ViewBag.ShowInactivityTimer = false;
|
||||
}
|
||||
|
||||
<div class="kiosk-card text-center py-5">
|
||||
<i class="bi bi-exclamation-triangle-fill text-warning" style="font-size:4rem;"></i>
|
||||
<h2 class="mt-3 fw-bold">Something went wrong</h2>
|
||||
<p class="text-muted mt-2">@Model</p>
|
||||
<p class="mt-4 text-muted" style="font-size:0.9rem;">Please ask a staff member for assistance.</p>
|
||||
</div>
|
||||
@@ -0,0 +1,68 @@
|
||||
@model PowderCoating.Application.DTOs.Kiosk.SendRemoteLinkDto
|
||||
@{
|
||||
ViewData["Title"] = "Send Intake Link";
|
||||
}
|
||||
|
||||
<div class="container-fluid px-4">
|
||||
<div class="d-flex align-items-center gap-3 mb-4">
|
||||
<i class="bi bi-envelope-at fs-3 text-primary"></i>
|
||||
<div>
|
||||
<h1 class="h3 fw-bold mb-0">Send Remote Intake Link</h1>
|
||||
<p class="text-muted mb-0">Email a customer an intake form they can fill out on their own device</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (TempData["Success"] != null)
|
||||
{
|
||||
<div class="alert alert-success alert-permanent mb-4">
|
||||
<i class="bi bi-check-circle me-2"></i> @TempData["Success"]
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post" asp-action="SendRemoteLink">
|
||||
@Html.AntiForgeryToken()
|
||||
<div asp-validation-summary="ModelOnly" class="alert alert-danger alert-permanent mb-3"></div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label asp-for="Email" class="form-label fw-semibold">Customer Email Address</label>
|
||||
<input asp-for="Email" class="form-control" type="email"
|
||||
placeholder="customer@example.com" autofocus />
|
||||
<span asp-validation-for="Email" class="text-danger small"></span>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label asp-for="CustomerName" class="form-label fw-semibold">
|
||||
Customer Name <span class="text-muted fw-normal">(optional)</span>
|
||||
</label>
|
||||
<input asp-for="CustomerName" class="form-control"
|
||||
placeholder="Used to personalise the email greeting" />
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-send me-2"></i> Send Intake Link
|
||||
</button>
|
||||
<a href="/Dashboard" class="btn btn-link ms-2">Cancel</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card bg-light border-0">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-semibold mb-2"><i class="bi bi-info-circle me-2 text-primary"></i>How it works</h6>
|
||||
<ul class="text-muted small mb-0" style="line-height:1.8;">
|
||||
<li>The customer receives an email with a unique, secure link.</li>
|
||||
<li>They fill out their contact info and describe their project on their own phone or computer.</li>
|
||||
<li>When they submit, a Pending job is automatically created and you're notified.</li>
|
||||
<li>The link expires in 48 hours.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,32 @@
|
||||
@{
|
||||
Layout = "~/Views/Shared/_KioskLayout.cshtml";
|
||||
ViewData["Title"] = "Welcome";
|
||||
}
|
||||
|
||||
<div id="kiosk-welcome-root"
|
||||
data-company-id="@ViewBag.CompanyId"
|
||||
class="kiosk-welcome-screen">
|
||||
|
||||
@if (!string.IsNullOrEmpty(ViewBag.CompanyLogoUrl as string))
|
||||
{
|
||||
<img src="@ViewBag.CompanyLogoUrl"
|
||||
alt="@ViewBag.CompanyName"
|
||||
class="kiosk-welcome-logo" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<h1 class="kiosk-welcome-title">@ViewBag.CompanyName</h1>
|
||||
}
|
||||
|
||||
<p class="kiosk-welcome-subtitle">Welcome! A staff member will start your intake shortly.</p>
|
||||
|
||||
<div class="kiosk-idle-indicator">
|
||||
<span id="kiosk-conn-dot" style="display:inline-block;width:10px;height:10px;
|
||||
border-radius:50%;background:#16a34a;margin-right:6px;transition:background 0.3s;"></span>
|
||||
Ready
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/kiosk-welcome.js"></script>
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,88 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
||||
<title>@(ViewData["Title"] ?? "Customer Intake") — @(ViewBag.CompanyName ?? "Intake Form")</title>
|
||||
<link href="~/lib/bootstrap/css/bootstrap.min.css" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="~/lib/bootstrap-icons/font/bootstrap-icons.css" />
|
||||
<link rel="stylesheet" href="~/css/kiosk.css" />
|
||||
@await RenderSectionAsync("Styles", required: false)
|
||||
</head>
|
||||
<body class="kiosk-body">
|
||||
|
||||
@{
|
||||
int kioskStep = ViewBag.KioskStep ?? 0; // 1, 2, or 3 — 0 means no step dots
|
||||
int kioskSteps = ViewBag.KioskSteps ?? 3;
|
||||
}
|
||||
|
||||
<div class="container py-4" style="max-width:720px;">
|
||||
|
||||
@* Logo *@
|
||||
<div class="text-center mb-3">
|
||||
@if (!string.IsNullOrEmpty(ViewBag.CompanyLogoUrl as string))
|
||||
{
|
||||
<img src="@ViewBag.CompanyLogoUrl" alt="@ViewBag.CompanyName"
|
||||
style="max-height:80px;max-width:220px;object-fit:contain;" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="fw-bold fs-5 text-muted">@ViewBag.CompanyName</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* Step dots *@
|
||||
@if (kioskStep > 0)
|
||||
{
|
||||
<div class="kiosk-steps mb-4" aria-label="Step @kioskStep of @kioskSteps">
|
||||
@for (int i = 1; i <= kioskSteps; i++)
|
||||
{
|
||||
string dotClass = i < kioskStep ? "done" : (i == kioskStep ? "active" : "");
|
||||
<div class="kiosk-step-dot @dotClass" title="Step @i"></div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Validation summary *@
|
||||
@if (ViewData.ModelState.IsValid == false)
|
||||
{
|
||||
<div class="alert alert-danger alert-permanent mb-4">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
Please correct the highlighted fields below.
|
||||
</div>
|
||||
}
|
||||
|
||||
@RenderBody()
|
||||
|
||||
</div>
|
||||
|
||||
@* Inactivity timer — redirect to Welcome after 5 minutes of no input *@
|
||||
@{
|
||||
bool showInactivityTimer = (bool)(ViewBag.ShowInactivityTimer ?? true);
|
||||
string welcomeUrl = ViewBag.WelcomeUrl as string ?? "/Kiosk/Welcome";
|
||||
}
|
||||
@if (showInactivityTimer)
|
||||
{
|
||||
<script>
|
||||
(function () {
|
||||
var TIMEOUT_MS = 5 * 60 * 1000;
|
||||
var timer;
|
||||
function reset() {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(function () {
|
||||
window.location.href = "@Html.Raw(welcomeUrl)";
|
||||
}, TIMEOUT_MS);
|
||||
}
|
||||
["touchstart", "touchmove", "click", "keydown", "scroll"].forEach(function (evt) {
|
||||
document.addEventListener(evt, reset, { passive: true });
|
||||
});
|
||||
reset();
|
||||
})();
|
||||
</script>
|
||||
}
|
||||
|
||||
<script src="~/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="~/lib/microsoft/signalr/dist/browser/signalr.min.js"></script>
|
||||
@await RenderSectionAsync("Scripts", required: false)
|
||||
</body>
|
||||
</html>
|
||||
@@ -1136,6 +1136,13 @@
|
||||
<span>Daily Board</span>
|
||||
</a>
|
||||
}
|
||||
@if (hasJobs)
|
||||
{
|
||||
<a asp-controller="Kiosk" asp-action="Intakes" class="nav-link" data-nav="ops">
|
||||
<i class="bi bi-tablet"></i>
|
||||
<span>Intake Sessions</span>
|
||||
</a>
|
||||
}
|
||||
|
||||
@* ── Billing & Payments ───────────────────────────────────── *@
|
||||
@if (hasInvoices)
|
||||
@@ -1492,6 +1499,7 @@
|
||||
<li><a class="dropdown-item" asp-controller="CompanyUsers" asp-action="Index"><i class="bi bi-people-fill me-2"></i>Manage Users</a></li>
|
||||
<li><a class="dropdown-item" asp-controller="PricingTiers" asp-action="Index"><i class="bi bi-tags me-2"></i>Pricing Tiers</a></li>
|
||||
<li><a class="dropdown-item" asp-controller="TaxRates" asp-action="Index"><i class="bi bi-percent me-2"></i>Tax Rates</a></li>
|
||||
<li><a class="dropdown-item" asp-controller="Kiosk" asp-action="Activate"><i class="bi bi-tablet me-2"></i>Kiosk Setup</a></li>
|
||||
}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
@if (gearIsAdmin)
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/* ── Kiosk touch-optimised styles ─────────────────────────────────────────── */
|
||||
|
||||
:root {
|
||||
--kiosk-accent: #2563eb;
|
||||
--kiosk-radius: 12px;
|
||||
--kiosk-input-h: 56px;
|
||||
--kiosk-btn-h: 64px;
|
||||
}
|
||||
|
||||
body.kiosk-body {
|
||||
font-size: 1.125rem;
|
||||
background: #f8fafc;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Inputs ── */
|
||||
.kiosk-input,
|
||||
.kiosk-body .form-control,
|
||||
.kiosk-body .form-select {
|
||||
min-height: var(--kiosk-input-h);
|
||||
border-radius: var(--kiosk-radius);
|
||||
font-size: 1.125rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid #cbd5e1;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.kiosk-body .form-control:focus,
|
||||
.kiosk-body .form-select:focus {
|
||||
border-color: var(--kiosk-accent);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
|
||||
}
|
||||
|
||||
.kiosk-body textarea.form-control {
|
||||
min-height: 140px;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.kiosk-body .form-check-input {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.kiosk-body .form-check-label {
|
||||
font-size: 1rem;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Buttons ── */
|
||||
.kiosk-btn,
|
||||
.kiosk-body .btn-primary,
|
||||
.kiosk-body .btn-success {
|
||||
min-height: var(--kiosk-btn-h);
|
||||
border-radius: var(--kiosk-radius);
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Suppress all hover effects on touch screens */
|
||||
@media (hover: none) {
|
||||
.kiosk-body .btn:hover { filter: none; opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── Step progress dots ── */
|
||||
.kiosk-steps {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
|
||||
.kiosk-step-dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #cbd5e1;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.kiosk-step-dot.active {
|
||||
background: var(--kiosk-accent);
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.kiosk-step-dot.done {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
/* ── Card ── */
|
||||
.kiosk-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ── Signature canvas ── */
|
||||
#signatureCanvas {
|
||||
border: 2px solid #cbd5e1;
|
||||
border-radius: var(--kiosk-radius);
|
||||
background: #fff;
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
#signatureCanvas.signed {
|
||||
border-color: #16a34a;
|
||||
}
|
||||
|
||||
/* ── Welcome screen ── */
|
||||
.kiosk-welcome-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100dvh;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.kiosk-welcome-logo {
|
||||
max-height: 120px;
|
||||
max-width: 280px;
|
||||
object-fit: contain;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.kiosk-welcome-title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.kiosk-welcome-subtitle {
|
||||
font-size: 1.25rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.kiosk-idle-indicator {
|
||||
margin-top: 3rem;
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* ── Confirmation screen ── */
|
||||
.kiosk-confirmation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.kiosk-confirmation-icon {
|
||||
font-size: 5rem;
|
||||
color: #16a34a;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.kiosk-countdown {
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
/* ── Terms scroll box ── */
|
||||
.kiosk-terms-scroll {
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--kiosk-radius);
|
||||
padding: 1.25rem;
|
||||
background: #f8fafc;
|
||||
font-size: 0.9rem;
|
||||
color: #475569;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* ── Labels ── */
|
||||
.kiosk-body .form-label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #1e293b;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,48 @@
|
||||
"use strict";
|
||||
|
||||
(function () {
|
||||
const el = document.getElementById("kiosk-welcome-root");
|
||||
if (!el) return;
|
||||
|
||||
const companyId = el.dataset.companyId;
|
||||
if (!companyId) return;
|
||||
|
||||
const connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl(`/hubs/kiosk?companyId=${companyId}`)
|
||||
.withAutomaticReconnect([2000, 5000, 10000, 30000])
|
||||
.configureLogging(signalR.LogLevel.Warning)
|
||||
.build();
|
||||
|
||||
connection.on("StartIntake", function (sessionToken) {
|
||||
window.location.href = `/Kiosk/Intake/${sessionToken}/Contact`;
|
||||
});
|
||||
|
||||
async function startConnection() {
|
||||
try {
|
||||
await connection.start();
|
||||
} catch (err) {
|
||||
console.warn("Kiosk SignalR connect failed, retrying in 10s...", err);
|
||||
setTimeout(startConnection, 10000);
|
||||
}
|
||||
}
|
||||
|
||||
startConnection();
|
||||
|
||||
// Show connection status indicator
|
||||
connection.onreconnecting(() => {
|
||||
const dot = document.getElementById("kiosk-conn-dot");
|
||||
if (dot) dot.style.background = "#f59e0b";
|
||||
});
|
||||
|
||||
connection.onreconnected(() => {
|
||||
const dot = document.getElementById("kiosk-conn-dot");
|
||||
if (dot) dot.style.background = "#16a34a";
|
||||
});
|
||||
|
||||
connection.onclose(() => {
|
||||
const dot = document.getElementById("kiosk-conn-dot");
|
||||
if (dot) dot.style.background = "#ef4444";
|
||||
// Keep retrying
|
||||
setTimeout(startConnection, 10000);
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user