328b195127
Alert sweep (113 alerts, 79 files):
All persistent static banners now carry alert-permanent so the
layout's 5-second auto-dismiss cannot swallow guidance, warnings,
or validation errors. Transient dismissible toasts left untouched.
CSS fixes (site.css):
.card.shadow-sm — strips rogue border from ~40 drifted cards
.card-header.bg-white — rebinds to var(--bs-body-bg) so card
headers follow dark/light theme correctly
Typography utilities — .text-2xs (.68rem), .text-xs (.73rem)
Token color classes — .text-ember, .text-ok, .text-bad,
.text-warn, .text-cool, .bg-paper-2
Layout utilities — .mw-xs/sm/md/lg replace inline max-width
Comment — documents text-ember vs text-primary intent
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1580 lines
92 KiB
Plaintext
1580 lines
92 KiB
Plaintext
@using PowderCoating.Application.DTOs.Invoice
|
|
@using PowderCoating.Core.Enums
|
|
@using PowderCoating.Web.Controllers
|
|
@model InvoiceDto
|
|
|
|
@{
|
|
ViewData["Title"] = $"Invoice {Model.InvoiceNumber}";
|
|
ViewData["PageIcon"] = "bi-receipt";
|
|
var statusColor = InvoicesController.GetStatusColorClass(Model.Status);
|
|
var statusDisplay = InvoicesController.GetStatusDisplay(Model.Status);
|
|
var isDraft = Model.Status == InvoiceStatus.Draft;
|
|
var isVoided = Model.Status == InvoiceStatus.Voided || Model.Status == InvoiceStatus.WrittenOff;
|
|
var canEdit = Model.Status is InvoiceStatus.Draft or InvoiceStatus.Sent or InvoiceStatus.Overdue;
|
|
var canPay = !isVoided && Model.BalanceDue > 0;
|
|
var canResend = !isDraft && !isVoided && Model.Status != InvoiceStatus.Paid;
|
|
var hasEmail = !string.IsNullOrWhiteSpace(Model.CustomerEmail);
|
|
var emailOptedOut = hasEmail && !Model.CustomerNotifyByEmail;
|
|
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;
|
|
string? paymentUrl = ViewBag.PaymentUrl as string;
|
|
var linkExpired = !string.IsNullOrEmpty(Model.PaymentLinkToken)
|
|
&& (Model.PaymentLinkExpiresAt == null || Model.PaymentLinkExpiresAt <= DateTime.UtcNow);
|
|
var onlinePaymentsEnabled = ViewBag.OnlinePaymentsEnabled == true;
|
|
var showOnlinePaymentCard = !isDraft && !isVoided && Model.BalanceDue > 0 && onlinePaymentsEnabled;
|
|
var guidedActivationCallout = ViewBag.GuidedActivationCallout as PowderCoating.Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel;
|
|
}
|
|
|
|
<div class="row justify-content-center">
|
|
<div class="col-lg-10">
|
|
|
|
<!-- Header -->
|
|
<div class="d-flex justify-content-end gap-2 mb-4">
|
|
@if (canEdit)
|
|
{
|
|
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-warning">
|
|
<i class="bi bi-pencil me-2"></i>Edit
|
|
</a>
|
|
}
|
|
<a asp-action="DownloadPdf" asp-route-id="@Model.Id" asp-route-inline="true"
|
|
class="btn btn-outline-secondary" target="_blank" rel="noopener">
|
|
<i class="bi bi-printer me-2"></i>Print
|
|
</a>
|
|
<a asp-action="DownloadPdf" asp-route-id="@Model.Id" class="btn btn-outline-secondary">
|
|
<i class="bi bi-file-pdf me-2"></i>PDF
|
|
</a>
|
|
<a asp-action="Index" class="btn btn-outline-secondary">
|
|
<i class="bi bi-arrow-left me-2"></i>Back
|
|
</a>
|
|
</div>
|
|
|
|
@if (TempData["Success"] != null)
|
|
{
|
|
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
|
<i class="bi bi-check-circle me-2"></i>@TempData["Success"]
|
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
|
</div>
|
|
}
|
|
@if (TempData["Error"] != null)
|
|
{
|
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
|
<i class="bi bi-exclamation-triangle me-2"></i>@TempData["Error"]
|
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
|
</div>
|
|
}
|
|
|
|
@if (!hasEmail)
|
|
{
|
|
<div class="alert alert-warning alert-permanent d-flex align-items-center gap-2 mb-4">
|
|
<i class="bi bi-envelope-slash fs-5"></i>
|
|
<span>
|
|
<strong>@Model.CustomerName</strong> has no email address on file — you'll be prompted to enter one when sending.
|
|
<a asp-controller="Customers" asp-action="Edit" asp-route-id="@Model.CustomerId" class="alert-link">Add one in customer settings</a>.
|
|
</span>
|
|
</div>
|
|
}
|
|
else if (emailOptedOut)
|
|
{
|
|
<div class="alert alert-warning alert-permanent d-flex align-items-center gap-2 mb-4">
|
|
<i class="bi bi-envelope-x fs-5"></i>
|
|
<span>
|
|
<strong>@Model.CustomerName</strong> has email notifications turned off.
|
|
Email buttons are disabled. You can change this in the
|
|
<a asp-controller="Customers" asp-action="Edit" asp-route-id="@Model.CustomerId" class="alert-link">customer settings</a>.
|
|
</span>
|
|
</div>
|
|
}
|
|
|
|
@if (guidedActivationCallout?.Show == true)
|
|
{
|
|
<div class="alert alert-success alert-permanent border-0 shadow-sm mb-4">
|
|
<div class="d-flex flex-column flex-lg-row gap-3 align-items-lg-center justify-content-between">
|
|
<div>
|
|
<div class="fw-semibold mb-1">@guidedActivationCallout.Title</div>
|
|
<div>@guidedActivationCallout.Message</div>
|
|
</div>
|
|
<div>
|
|
<a asp-controller="Dashboard" asp-action="Index" class="btn btn-success">
|
|
@guidedActivationCallout.ActionText
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<!-- Status Banner -->
|
|
<div class="alert alert-@statusColor alert-permanent d-flex align-items-center mb-4">
|
|
<i class="bi bi-info-circle me-2" style="font-size:1.4rem;"></i>
|
|
<div>
|
|
<strong>Status:</strong> @statusDisplay
|
|
@if (Model.BalanceDue > 0 && !isVoided)
|
|
{
|
|
<span class="ms-3">Balance Due: <strong>@Model.BalanceDue.ToString("C")</strong></span>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row g-4">
|
|
<!-- Left: Main Content -->
|
|
<div class="col-lg-8">
|
|
|
|
<!-- Invoice Info -->
|
|
<div class="card border-0 shadow-sm mb-4">
|
|
<div class="card-header bg-white border-0 py-3">
|
|
<h5 class="mb-0 fw-semibold"><i class="bi bi-receipt me-2 text-primary"></i>Invoice Information</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="row g-3">
|
|
<div class="col-md-6">
|
|
<label class="text-muted small mb-1">Invoice Number</label>
|
|
<p class="fw-semibold mb-0">@Model.InvoiceNumber</p>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="text-muted small mb-1">Status</label>
|
|
<p class="mb-0">
|
|
<span class="badge bg-@statusColor">@statusDisplay</span>
|
|
</p>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="text-muted small mb-1">Customer</label>
|
|
<p class="mb-0">
|
|
<a asp-controller="Customers" asp-action="Details" asp-route-id="@Model.CustomerId"
|
|
class="text-decoration-none fw-semibold">@Model.CustomerName</a>
|
|
</p>
|
|
@if (!string.IsNullOrWhiteSpace(Model.CustomerEmail))
|
|
{
|
|
<p class="text-muted small mb-0">@Model.CustomerEmail</p>
|
|
}
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="text-muted small mb-1">Job</label>
|
|
<p class="mb-0">
|
|
@if (Model.JobId.HasValue)
|
|
{
|
|
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@Model.JobId"
|
|
class="text-decoration-none">@Model.JobNumber</a>
|
|
}
|
|
else
|
|
{
|
|
<span class="badge bg-success-subtle text-success">Merchandise Sale</span>
|
|
}
|
|
</p>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="text-muted small mb-1">Invoice Date</label>
|
|
<p class="mb-0">@Model.InvoiceDate.ToString("MMMM d, yyyy")</p>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="text-muted small mb-1">Due Date</label>
|
|
<p class="mb-0 @(Model.Status == InvoiceStatus.Overdue ? "text-danger fw-bold" : "")">
|
|
@(Model.DueDate.HasValue ? Model.DueDate.Value.ToString("MMMM d, yyyy") : "—")
|
|
</p>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="text-muted small mb-1">Sent Date</label>
|
|
<p class="mb-0">@(Model.SentDate.HasValue ? Model.SentDate.Value.ToString("MMMM d, yyyy") : "—")</p>
|
|
</div>
|
|
@if (!string.IsNullOrWhiteSpace(Model.CustomerPO))
|
|
{
|
|
<div class="col-md-6">
|
|
<label class="text-muted small mb-1">Customer PO</label>
|
|
<p class="mb-0">@Model.CustomerPO</p>
|
|
</div>
|
|
}
|
|
@if (!string.IsNullOrWhiteSpace(Model.ExternalReference))
|
|
{
|
|
<div class="col-md-6">
|
|
<label class="text-muted small mb-1">QB Invoice #</label>
|
|
<p class="mb-0">@Model.ExternalReference</p>
|
|
</div>
|
|
}
|
|
@if (!string.IsNullOrWhiteSpace(Model.Terms))
|
|
{
|
|
<div class="col-md-6">
|
|
<label class="text-muted small mb-1">Payment Terms</label>
|
|
<p class="mb-0">@Model.Terms</p>
|
|
</div>
|
|
}
|
|
@if (!string.IsNullOrWhiteSpace(Model.PreparedByName))
|
|
{
|
|
<div class="col-md-6">
|
|
<label class="text-muted small mb-1">Prepared By</label>
|
|
<p class="mb-0">@Model.PreparedByName</p>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Line Items -->
|
|
<div class="card border-0 shadow-sm mb-4">
|
|
<div class="card-header bg-white border-0 py-3">
|
|
<h5 class="mb-0 fw-semibold"><i class="bi bi-list-ul me-2 text-primary"></i>Line Items</h5>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>Description</th>
|
|
<th class="text-center" style="width:80px;">Qty</th>
|
|
<th class="text-end" style="width:110px;">Unit Price</th>
|
|
<th class="text-end" style="width:110px;">Total</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var item in Model.InvoiceItems)
|
|
{
|
|
<tr>
|
|
<td>
|
|
<div class="fw-semibold">@item.Description</div>
|
|
@if (!string.IsNullOrWhiteSpace(item.ColorName))
|
|
{
|
|
<small class="text-muted">@item.ColorName</small>
|
|
}
|
|
@if (!string.IsNullOrWhiteSpace(item.Notes))
|
|
{
|
|
<small class="text-muted d-block">@item.Notes</small>
|
|
}
|
|
</td>
|
|
<td class="text-center">@item.Quantity.ToString("G")</td>
|
|
<td class="text-end">@item.UnitPrice.ToString("C")</td>
|
|
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Totals -->
|
|
<div class="p-3 border-top">
|
|
<div class="row justify-content-end">
|
|
<div class="col-md-4">
|
|
<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.TaxPercent > 0)
|
|
{
|
|
<div class="d-flex justify-content-between mb-1 align-items-center">
|
|
<span class="text-muted">
|
|
Tax (@Model.TaxPercent.ToString("G")%)
|
|
@if (!string.IsNullOrEmpty(Model.SalesTaxAccountName))
|
|
{
|
|
<span class="badge bg-warning-subtle text-warning-emphasis ms-1 small fw-normal">@Model.SalesTaxAccountName</span>
|
|
}
|
|
</span>
|
|
<span>@Model.TaxAmount.ToString("C")</span>
|
|
</div>
|
|
}
|
|
<div class="d-flex justify-content-between fw-bold border-top pt-2 mt-1 fs-5">
|
|
<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>
|
|
<div class="d-flex justify-content-between fw-bold border-top pt-2 mt-1">
|
|
<span>Balance Due</span>
|
|
<span class="@(Model.BalanceDue > 0 ? "text-danger" : "text-success")">@Model.BalanceDue.ToString("C")</span>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
@if (!string.IsNullOrWhiteSpace(Model.Notes))
|
|
{
|
|
<div class="card border-0 shadow-sm mb-4">
|
|
<div class="card-header bg-white border-0 py-3">
|
|
<h5 class="mb-0 fw-semibold"><i class="bi bi-chat-text me-2 text-primary"></i>Notes</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<p class="mb-0" style="white-space:pre-wrap;">@Model.Notes</p>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<!-- Gift Certificates Issued from this Invoice -->
|
|
@{
|
|
var issuedGcs = Model.InvoiceItems.Where(i => i.IsGiftCertificate && i.GeneratedGiftCertificateId.HasValue).ToList();
|
|
}
|
|
@if (issuedGcs.Any())
|
|
{
|
|
<div class="card border-0 shadow-sm mb-4">
|
|
<div class="card-header bg-white border-0 py-3">
|
|
<h5 class="mb-0 fw-semibold"><i class="bi bi-gift me-2 text-warning"></i>Gift Certificates Issued</h5>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>Certificate Code</th>
|
|
<th>Recipient</th>
|
|
<th class="text-end">Face Value</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var gcItem in issuedGcs)
|
|
{
|
|
<tr>
|
|
<td>
|
|
<span class="badge bg-warning-subtle text-warning-emphasis font-monospace fs-6">
|
|
@gcItem.GeneratedGiftCertificateCode
|
|
</span>
|
|
</td>
|
|
<td class="text-muted">
|
|
@(gcItem.Description.Contains("for ") ? gcItem.Description.Substring(gcItem.Description.IndexOf("for ") + 4).TrimEnd(')') : "—")
|
|
</td>
|
|
<td class="text-end fw-semibold">@gcItem.TotalPrice.ToString("C")</td>
|
|
<td>
|
|
<a asp-controller="GiftCertificates" asp-action="Details"
|
|
asp-route-id="@gcItem.GeneratedGiftCertificateId"
|
|
class="btn btn-sm btn-outline-warning">
|
|
<i class="bi bi-eye"></i>
|
|
</a>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<!-- Payment History -->
|
|
@if (Model.Payments.Any())
|
|
{
|
|
<div class="card border-0 shadow-sm mb-4">
|
|
<div class="card-header bg-white border-0 py-3">
|
|
<h5 class="mb-0 fw-semibold"><i class="bi bi-cash-stack me-2 text-success"></i>Payment History</h5>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>Date</th>
|
|
<th>Method</th>
|
|
<th>Reference</th>
|
|
<th>Deposited To</th>
|
|
<th>Recorded By</th>
|
|
<th class="text-end">Amount</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var p in Model.Payments)
|
|
{
|
|
<tr>
|
|
<td>@p.PaymentDate.ToString("MM/dd/yyyy")</td>
|
|
<td>@p.PaymentMethodDisplay</td>
|
|
<td>@(p.Reference ?? "—")</td>
|
|
<td>
|
|
@if (!string.IsNullOrEmpty(p.DepositAccountName))
|
|
{
|
|
<span class="badge bg-success-subtle text-success small">@p.DepositAccountName</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="text-muted">—</span>
|
|
}
|
|
</td>
|
|
<td>@(p.RecordedByName ?? "—")</td>
|
|
<td class="text-end fw-semibold text-success">@p.Amount.ToString("C")</td>
|
|
<td class="text-end">
|
|
@if (!isVoided)
|
|
{
|
|
<button type="button" class="btn btn-sm btn-outline-secondary me-1" title="Edit payment"
|
|
onclick="openEditPaymentModal(@p.Id, @Model.Id, '@p.PaymentDate.ToString("yyyy-MM-dd")', @((int)p.PaymentMethod), '@(p.Reference ?? "")', '@(p.Notes ?? "")', @(p.DepositAccountId?.ToString() ?? "null"))">
|
|
<i class="bi bi-pencil"></i>
|
|
</button>
|
|
<form asp-action="DeletePayment" asp-route-invoiceId="@Model.Id" asp-route-paymentId="@p.Id"
|
|
method="post" class="d-inline"
|
|
onsubmit="return confirm('Delete this payment and reverse the balance?')">
|
|
@Html.AntiForgeryToken()
|
|
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete payment">
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</form>
|
|
}
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
<!-- Refund History -->
|
|
@if (Model.Refunds.Any())
|
|
{
|
|
<div class="card border-0 shadow-sm mb-4">
|
|
<div class="card-header bg-white border-0 py-3">
|
|
<h5 class="mb-0 fw-semibold"><i class="bi bi-arrow-counterclockwise me-2 text-danger"></i>Refunds Issued</h5>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>Date</th>
|
|
<th>Method</th>
|
|
<th>Reason</th>
|
|
<th>Reference</th>
|
|
<th>Status</th>
|
|
<th class="text-end">Amount</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var r in Model.Refunds)
|
|
{
|
|
var refundStatusColor = r.Status == RefundStatus.Issued ? "success" : r.Status == RefundStatus.Cancelled ? "secondary" : "warning";
|
|
<tr>
|
|
<td>@r.RefundDate.ToString("MM/dd/yyyy")</td>
|
|
<td>@r.RefundMethodDisplay</td>
|
|
<td>@r.Reason</td>
|
|
<td>@(r.Reference ?? "—")</td>
|
|
<td><span class="badge bg-@refundStatusColor">@r.Status</span></td>
|
|
<td class="text-end fw-semibold text-danger">(@r.Amount.ToString("C"))</td>
|
|
<td class="text-nowrap">
|
|
@if (r.Status == RefundStatus.Pending)
|
|
{
|
|
<form asp-action="MarkRefundIssued" asp-route-refundId="@r.Id" method="post" class="d-inline">
|
|
@Html.AntiForgeryToken()
|
|
<button type="submit" class="btn btn-sm btn-outline-success me-1" title="Mark as issued">
|
|
<i class="bi bi-check"></i>
|
|
</button>
|
|
</form>
|
|
<form asp-action="CancelRefund" asp-route-refundId="@r.Id" method="post" class="d-inline"
|
|
onsubmit="return confirm('Cancel this refund? The customer balance will be reversed.')">
|
|
@Html.AntiForgeryToken()
|
|
<button type="submit" class="btn btn-sm btn-outline-secondary" title="Cancel refund">
|
|
<i class="bi bi-x"></i>
|
|
</button>
|
|
</form>
|
|
}
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<!-- Gift Certificate Redemptions -->
|
|
@if (Model.GiftCertificateRedemptions.Any())
|
|
{
|
|
<div class="card border-0 shadow-sm mb-4">
|
|
<div class="card-header bg-white border-0 py-3">
|
|
<h5 class="mb-0 fw-semibold"><i class="bi bi-gift me-2 text-success"></i>Gift Certificates Applied</h5>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>Date</th>
|
|
<th>Certificate</th>
|
|
<th class="text-end">Amount Applied</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var gr in Model.GiftCertificateRedemptions)
|
|
{
|
|
<tr>
|
|
<td>@gr.RedeemedDate.ToString("MM/dd/yyyy")</td>
|
|
<td>
|
|
<a asp-controller="GiftCertificates" asp-action="Details"
|
|
asp-route-id="@gr.GiftCertificateId" class="text-decoration-none">
|
|
<span class="badge bg-success-subtle text-success font-monospace">GC-@gr.GiftCertificateId.ToString("D4")</span>
|
|
</a>
|
|
</td>
|
|
<td class="text-end fw-semibold text-success">(@gr.AmountRedeemed.ToString("C"))</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<!-- Credit Applications -->
|
|
@if (Model.CreditApplications.Any())
|
|
{
|
|
<div class="card border-0 shadow-sm mb-4">
|
|
<div class="card-header bg-white border-0 py-3">
|
|
<h5 class="mb-0 fw-semibold"><i class="bi bi-gift me-2 text-info"></i>Credits Applied</h5>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>Date</th>
|
|
<th>Credit Memo</th>
|
|
<th class="text-end">Amount Applied</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var ca in Model.CreditApplications)
|
|
{
|
|
<tr>
|
|
<td>@ca.AppliedDate.ToString("MM/dd/yyyy")</td>
|
|
<td><span class="badge bg-info-subtle text-info">@ca.MemoNumber</span></td>
|
|
<td class="text-end fw-semibold text-info">(@ca.AmountApplied.ToString("C"))</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
<!-- Right: Actions -->
|
|
<div class="col-lg-4">
|
|
<!-- Actions Card -->
|
|
<div class="card border-0 shadow-sm mb-4">
|
|
<div class="card-header bg-white border-0 py-3">
|
|
<div class="d-flex align-items-center gap-2">
|
|
<h5 class="mb-0 fw-semibold"><i class="bi bi-lightning me-2 text-primary"></i>Actions</h5>
|
|
<a tabindex="0" class="help-icon" role="button"
|
|
data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus"
|
|
data-bs-title="Invoice Actions"
|
|
data-bs-content="Workflow: Edit (Draft only) → Send Invoice (locks it, emails customer) → Record Payment. Partial payments are supported — record multiple payments until fully paid. Void cancels the invoice and reverses the customer balance without deleting history. Delete is only available for Drafts.">
|
|
<i class="bi bi-question-circle"></i>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="d-grid gap-2">
|
|
@if (canEdit)
|
|
{
|
|
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-outline-primary">
|
|
<i class="bi bi-pencil me-2"></i>Edit Invoice
|
|
</a>
|
|
<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)
|
|
{
|
|
<button type="button" class="btn btn-primary w-100" disabled
|
|
title="Email notifications are turned off for this customer">
|
|
<i class="bi bi-send me-2"></i>Send Invoice
|
|
</button>
|
|
}
|
|
else if (hasEmail)
|
|
{
|
|
<button type="button" class="btn btn-primary w-100"
|
|
data-bs-toggle="modal" data-bs-target="#sendInvoiceModal">
|
|
<i class="bi bi-send me-2"></i>Send Invoice
|
|
</button>
|
|
}
|
|
else
|
|
{
|
|
<button type="button" class="btn btn-primary w-100"
|
|
data-bs-toggle="modal" data-bs-target="#sendToAdHocEmailModal"
|
|
onclick="document.getElementById('adHocEmailMode').value='send'">
|
|
<i class="bi bi-send me-2"></i>Send Invoice
|
|
</button>
|
|
}
|
|
</form>
|
|
}
|
|
@if (canPay)
|
|
{
|
|
<button type="button" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#recordPaymentModal">
|
|
<i class="bi bi-cash me-2"></i>Record Payment
|
|
</button>
|
|
}
|
|
<a asp-action="DownloadPdf" asp-route-id="@Model.Id" asp-route-inline="true"
|
|
class="btn btn-outline-secondary" target="_blank" rel="noopener">
|
|
<i class="bi bi-printer me-2"></i>Print
|
|
</a>
|
|
<a asp-action="DownloadPdf" asp-route-id="@Model.Id" class="btn btn-outline-secondary">
|
|
<i class="bi bi-file-pdf me-2"></i>Download PDF
|
|
</a>
|
|
@if (canResend)
|
|
{
|
|
@if (!hasEmail)
|
|
{
|
|
<button type="button" class="btn btn-outline-primary"
|
|
data-bs-toggle="modal" data-bs-target="#sendToAdHocEmailModal">
|
|
<i class="bi bi-send me-2"></i>Send Invoice
|
|
</button>
|
|
}
|
|
else if (emailOptedOut)
|
|
{
|
|
<button type="button" class="btn btn-outline-primary" disabled
|
|
title="Email notifications are turned off for this customer">
|
|
<i class="bi bi-send me-2"></i>Re-send Invoice
|
|
</button>
|
|
}
|
|
else
|
|
{
|
|
<button type="button" class="btn btn-outline-primary" onclick="resendInvoice(@Model.Id)">
|
|
<i class="bi bi-send me-2"></i>Re-send Invoice
|
|
</button>
|
|
}
|
|
}
|
|
<button type="button" class="btn btn-outline-secondary" onclick="loadInvoiceNotifications(@Model.Id)">
|
|
<i class="bi bi-bell me-2"></i>View Notifications Sent
|
|
</button>
|
|
@if (canIssueRefund)
|
|
{
|
|
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#issueRefundModal">
|
|
<i class="bi bi-arrow-counterclockwise me-2"></i>Issue Refund
|
|
</button>
|
|
}
|
|
<button type="button" class="btn btn-outline-info" data-bs-toggle="modal" data-bs-target="#issueCreditModal">
|
|
<i class="bi bi-gift me-2"></i>Issue Credit Memo
|
|
</button>
|
|
@if (canApplyCredit)
|
|
{
|
|
<button type="button" class="btn btn-outline-success" data-bs-toggle="modal" data-bs-target="#applyCreditModal">
|
|
<i class="bi bi-check2-circle me-2"></i>Apply Credit
|
|
</button>
|
|
}
|
|
@if (canPay)
|
|
{
|
|
<button type="button" class="btn btn-outline-success" data-bs-toggle="modal" data-bs-target="#applyGiftCertModal">
|
|
<i class="bi bi-gift me-2"></i>Apply Gift Certificate
|
|
</button>
|
|
}
|
|
@if (Model.JobId.HasValue)
|
|
{
|
|
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@Model.JobId" class="btn btn-outline-info">
|
|
<i class="bi bi-briefcase me-2"></i>View Job
|
|
</a>
|
|
}
|
|
@if (!isVoided && Model.Status != InvoiceStatus.Paid)
|
|
{
|
|
<form asp-action="Void" asp-route-id="@Model.Id" method="post"
|
|
onsubmit="return confirm('Void this invoice? This will reverse the remaining balance on the customer account.')">
|
|
@Html.AntiForgeryToken()
|
|
<button type="submit" class="btn btn-outline-warning w-100">
|
|
<i class="bi bi-x-circle me-2"></i>Void Invoice
|
|
</button>
|
|
</form>
|
|
}
|
|
@if (!isVoided && Model.BalanceDue > 0)
|
|
{
|
|
<button type="button" class="btn btn-outline-danger w-100"
|
|
data-bs-toggle="modal" data-bs-target="#writeOffModal">
|
|
<i class="bi bi-journal-x me-2"></i>Write Off
|
|
</button>
|
|
}
|
|
@if (isDraft)
|
|
{
|
|
<form asp-action="Delete" asp-route-id="@Model.Id" method="post"
|
|
onsubmit="return confirm('Delete this draft invoice?')">
|
|
@Html.AntiForgeryToken()
|
|
<button type="submit" class="btn btn-outline-danger w-100">
|
|
<i class="bi bi-trash me-2"></i>Delete Invoice
|
|
</button>
|
|
</form>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Summary Card -->
|
|
<div class="card border-0 shadow-sm">
|
|
<div class="card-header bg-white border-0 py-3">
|
|
<h5 class="mb-0 fw-semibold"><i class="bi bi-bar-chart me-2 text-primary"></i>Summary</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="d-flex justify-content-between mb-2">
|
|
<span class="text-muted">Invoice Total</span>
|
|
<span class="fw-semibold">@Model.Total.ToString("C")</span>
|
|
</div>
|
|
<div class="d-flex justify-content-between mb-2 text-success">
|
|
<span>Amount Paid</span>
|
|
<span class="fw-semibold">@Model.AmountPaid.ToString("C")</span>
|
|
</div>
|
|
@if (Model.CreditApplied > 0)
|
|
{
|
|
<div class="d-flex justify-content-between mb-2 text-info">
|
|
<span>Credits Applied</span>
|
|
<span class="fw-semibold">(@Model.CreditApplied.ToString("C"))</span>
|
|
</div>
|
|
}
|
|
@if (Model.GiftCertificateRedeemed > 0)
|
|
{
|
|
<div class="d-flex justify-content-between mb-2 text-success">
|
|
<span>Gift Certificate</span>
|
|
<span class="fw-semibold">(@Model.GiftCertificateRedeemed.ToString("C"))</span>
|
|
</div>
|
|
}
|
|
<div class="d-flex justify-content-between border-top pt-2 @(Model.BalanceDue > 0 ? "text-danger" : "text-success")">
|
|
<span class="fw-bold">Balance Due</span>
|
|
<span class="fw-bold fs-5">@Model.BalanceDue.ToString("C")</span>
|
|
</div>
|
|
@if (Model.PaidDate.HasValue)
|
|
{
|
|
<div class="mt-2 text-muted small">
|
|
<i class="bi bi-check-circle text-success me-1"></i>
|
|
Paid in full on @Model.PaidDate.Value.ToString("MMMM d, yyyy")
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
@if (showOnlinePaymentCard)
|
|
{
|
|
<!-- Online Payment Card -->
|
|
<div class="card border-0 shadow-sm mt-4" id="onlinePaymentCard">
|
|
<div class="card-header bg-white border-0 py-3">
|
|
<h5 class="mb-0 fw-semibold"><i class="bi bi-credit-card me-2 text-primary"></i>Online Payment Link</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
@if (Model.OnlineAmountPaid > 0)
|
|
{
|
|
<div class="d-flex justify-content-between mb-3 text-success small">
|
|
<span><i class="bi bi-stripe me-1"></i>Collected online</span>
|
|
<span class="fw-semibold">@Model.OnlineAmountPaid.ToString("C")</span>
|
|
</div>
|
|
}
|
|
@if (!string.IsNullOrEmpty(paymentUrl))
|
|
{
|
|
<div class="mb-2">
|
|
<span class="badge bg-success-subtle text-success mb-2">
|
|
<i class="bi bi-check-circle me-1"></i>Active — expires @Model.PaymentLinkExpiresAt!.Value.ToString("MMM d")
|
|
</span>
|
|
<div class="input-group input-group-sm">
|
|
<input type="text" id="paymentLinkInput" class="form-control font-monospace"
|
|
value="@paymentUrl" readonly />
|
|
<button class="btn btn-outline-secondary" type="button"
|
|
onclick="copyPaymentLink()" title="Copy link">
|
|
<i class="bi bi-clipboard" id="copyIcon"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="d-grid gap-2 mt-2">
|
|
<a href="@paymentUrl" target="_blank" class="btn btn-outline-primary btn-sm">
|
|
<i class="bi bi-box-arrow-up-right me-1"></i>Preview Payment Page
|
|
</a>
|
|
<button type="button" class="btn btn-outline-secondary btn-sm"
|
|
onclick="regeneratePaymentLink(@Model.Id)">
|
|
<i class="bi bi-arrow-clockwise me-1"></i>Regenerate Link
|
|
</button>
|
|
</div>
|
|
}
|
|
else if (linkExpired)
|
|
{
|
|
<div class="alert alert-warning alert-permanent py-2 small mb-2">
|
|
<i class="bi bi-clock me-1"></i>Payment link expired.
|
|
</div>
|
|
<button type="button" class="btn btn-primary btn-sm w-100"
|
|
onclick="regeneratePaymentLink(@Model.Id)">
|
|
<i class="bi bi-arrow-clockwise me-1"></i>Generate New Link
|
|
</button>
|
|
}
|
|
else
|
|
{
|
|
<p class="text-muted small mb-2">No payment link yet. A link is auto-generated when you send the invoice, or you can create one now.</p>
|
|
<button type="button" class="btn btn-primary btn-sm w-100"
|
|
onclick="regeneratePaymentLink(@Model.Id)">
|
|
<i class="bi bi-link-45deg me-1"></i>Generate Payment Link
|
|
</button>
|
|
}
|
|
<div id="onlinePaymentMsg" class="mt-2"></div>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
@* Hidden anti-forgery token for AJAX calls *@
|
|
@Html.AntiForgeryToken()
|
|
|
|
@if (isDraft)
|
|
{
|
|
<!-- Send Invoice Confirmation Modal -->
|
|
<div class="modal fade" id="sendInvoiceModal" tabindex="-1" aria-labelledby="sendInvoiceModalLabel" 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="sendInvoiceModalLabel">
|
|
<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">Mark <strong>@Model.InvoiceNumber</strong> as sent and notify the customer?</p>
|
|
<div class="rounded bg-light p-3 small">
|
|
<div class="d-flex justify-content-between mb-1">
|
|
<span class="text-muted">Customer</span>
|
|
<span class="fw-medium">@Model.CustomerName</span>
|
|
</div>
|
|
<div class="d-flex justify-content-between">
|
|
<span class="text-muted">Amount Due</span>
|
|
<span class="fw-bold text-primary">@Model.Total.ToString("C")</span>
|
|
</div>
|
|
</div>
|
|
</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()">
|
|
<i class="bi bi-send me-1"></i>Yes, Send Invoice
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@if (canPay)
|
|
{
|
|
<!-- Record Payment Modal -->
|
|
<div class="modal fade" id="recordPaymentModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><i class="bi bi-cash me-2"></i>Record Payment</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<form asp-action="RecordPayment" asp-route-id="@Model.Id" method="post">
|
|
@Html.AntiForgeryToken()
|
|
<div class="modal-body">
|
|
<div class="mb-3">
|
|
<label class="form-label fw-semibold">Amount <span class="text-danger">*</span></label>
|
|
<div class="input-group">
|
|
<span class="input-group-text">$</span>
|
|
<input type="number" name="Amount" class="form-control" step="0.01" min="0.01"
|
|
max="@Model.BalanceDue" value="@Model.BalanceDue.ToString("F2")" required />
|
|
</div>
|
|
<div class="form-text">Balance due: @Model.BalanceDue.ToString("C")</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label fw-semibold">Payment Date <span class="text-danger">*</span></label>
|
|
<input type="date" name="PaymentDate" class="form-control"
|
|
value="@DateTime.Today.ToString("yyyy-MM-dd")" required />
|
|
</div>
|
|
<div class="mb-3">
|
|
<div class="d-flex align-items-center gap-1">
|
|
<label class="form-label fw-semibold mb-0">Payment Method <span class="text-danger">*</span></label>
|
|
<a tabindex="0" class="help-icon" role="button"
|
|
data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus"
|
|
data-bs-title="Payment Method"
|
|
data-bs-content="How the customer paid. This is recorded for your accounting records and appears in payment history. 'Digital Payment' covers Venmo, PayPal, Zelle, and similar apps.">
|
|
<i class="bi bi-question-circle"></i>
|
|
</a>
|
|
</div>
|
|
<select name="PaymentMethod" class="form-select" required>
|
|
<option value="0">Cash</option>
|
|
<option value="1">Check</option>
|
|
<option value="2">Credit/Debit Card</option>
|
|
<option value="3">Bank Transfer / ACH</option>
|
|
<option value="4">Digital Payment (Venmo/PayPal/Zelle)</option>
|
|
</select>
|
|
</div>
|
|
<div class="mb-3">
|
|
<div class="d-flex align-items-center gap-1">
|
|
<label class="form-label mb-0">Reference <small class="text-muted">(check #, last 4, ACH ref)</small></label>
|
|
<a tabindex="0" class="help-icon" role="button"
|
|
data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus"
|
|
data-bs-title="Payment Reference"
|
|
data-bs-content="Optional identifier for reconciliation — e.g., the check number, last 4 digits of the card, ACH transaction ID, or Venmo/PayPal confirmation code. Appears in payment history so you can match payments to your bank statement.">
|
|
<i class="bi bi-question-circle"></i>
|
|
</a>
|
|
</div>
|
|
<input type="text" name="Reference" class="form-control" placeholder="Optional" />
|
|
</div>
|
|
<div class="mb-3">
|
|
<div class="d-flex align-items-center gap-1">
|
|
<label class="form-label mb-0">Deposit To Account</label>
|
|
<a tabindex="0" class="help-icon" role="button"
|
|
data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus"
|
|
data-bs-title="Deposit To Account"
|
|
data-bs-content="Which bank or cash account received this payment. Used for accounting exports and bank reconciliation. If you don't track accounts in this system, leave it as '(Not tracked)'.">
|
|
<i class="bi bi-question-circle"></i>
|
|
</a>
|
|
</div>
|
|
<select name="DepositAccountId" class="form-select">
|
|
<option value="">(Not tracked)</option>
|
|
@if (ViewBag.BankAccounts != null)
|
|
{
|
|
@foreach (var acct in (IEnumerable<SelectListItem>)ViewBag.BankAccounts)
|
|
{
|
|
<option value="@acct.Value">@acct.Text</option>
|
|
}
|
|
}
|
|
</select>
|
|
<div class="form-text">Bank or checking account receiving this payment.</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Notes</label>
|
|
<textarea name="Notes" class="form-control" rows="2" placeholder="Optional"></textarea>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="submit" class="btn btn-success">
|
|
<i class="bi bi-check-circle me-2"></i>Record Payment
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<!-- Edit Payment Modal -->
|
|
@if (!isVoided)
|
|
{
|
|
<div class="modal fade" id="editPaymentModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><i class="bi bi-pencil me-2"></i>Edit Payment</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<form asp-action="EditPayment" method="post">
|
|
@Html.AntiForgeryToken()
|
|
<input type="hidden" name="PaymentId" id="editPaymentId" />
|
|
<input type="hidden" name="InvoiceId" value="@Model.Id" />
|
|
<div class="modal-body">
|
|
<div class="mb-3">
|
|
<label class="form-label fw-semibold">Payment Date <span class="text-danger">*</span></label>
|
|
<input type="date" name="PaymentDate" id="editPaymentDate" class="form-control" required />
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label fw-semibold">Payment Method <span class="text-danger">*</span></label>
|
|
<select name="PaymentMethod" id="editPaymentMethod" class="form-select" required>
|
|
<option value="0">Cash</option>
|
|
<option value="1">Check</option>
|
|
<option value="2">Credit/Debit Card</option>
|
|
<option value="3">Bank Transfer / ACH</option>
|
|
<option value="4">Digital Payment (Venmo/PayPal/Zelle)</option>
|
|
</select>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Reference <small class="text-muted">(check #, last 4, ACH ref)</small></label>
|
|
<input type="text" name="Reference" id="editPaymentReference" class="form-control" />
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Deposit To Account</label>
|
|
<select name="DepositAccountId" id="editPaymentDepositAccount" class="form-select">
|
|
<option value="">(Not tracked)</option>
|
|
@if (ViewBag.BankAccounts != null)
|
|
{
|
|
@foreach (var acct in (IEnumerable<SelectListItem>)ViewBag.BankAccounts)
|
|
{
|
|
<option value="@acct.Value">@acct.Text</option>
|
|
}
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Notes</label>
|
|
<textarea name="Notes" id="editPaymentNotes" class="form-control" rows="2"></textarea>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="submit" class="btn btn-primary">
|
|
<i class="bi bi-check-circle me-2"></i>Save Changes
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<!-- Send to Ad-hoc Email Modal -->
|
|
<div class="modal fade" id="sendToAdHocEmailModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><i class="bi bi-send 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">
|
|
<p class="text-muted mb-3">No email address is on file for this customer. Enter an address below to send the invoice.</p>
|
|
<div class="mb-3">
|
|
<label for="adHocEmailInput" class="form-label fw-medium">Send To</label>
|
|
<input type="email" id="adHocEmailInput" class="form-control" placeholder="recipient@example.com" />
|
|
<div class="form-text">This address will not be saved to the customer record.</div>
|
|
</div>
|
|
<div id="adHocEmailError" class="alert alert-danger alert-permanent d-none py-2"></div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<input type="hidden" id="adHocEmailMode" value="resend" />
|
|
<button type="button" class="btn btn-primary" onclick="sendToAdHocEmail(@Model.Id)">
|
|
<i class="bi bi-send me-1"></i>Send Invoice
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Re-send Invoice Modal (AJAX) -->
|
|
<div class="modal fade" id="resendInvoiceModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header" id="resendInvoiceModalHeader">
|
|
<h5 class="modal-title"><i class="bi bi-send me-2"></i>Re-send Invoice</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body text-center py-4">
|
|
<div id="resendInvoiceSending">
|
|
<div class="spinner-border text-primary mb-3" role="status"></div>
|
|
<div class="text-muted">Sending invoice...</div>
|
|
</div>
|
|
<div id="resendInvoiceResult" class="d-none">
|
|
<i id="resendInvoiceIcon" class="fs-1 d-block mb-3"></i>
|
|
<p id="resendInvoiceMessage" class="mb-0"></p>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer d-none" id="resendInvoiceFooter">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Notifications Sent Modal -->
|
|
<div class="modal fade" id="invoiceNotificationsModal" tabindex="-1" aria-labelledby="invoiceNotificationsModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog modal-lg">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="invoiceNotificationsModalLabel">
|
|
<i class="bi bi-bell me-2"></i>Notifications Sent
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div id="invNotifLoading" class="text-center py-4">
|
|
<div class="spinner-border text-primary" role="status"></div>
|
|
<div class="mt-2 text-muted">Loading...</div>
|
|
</div>
|
|
<div id="invNotifEmpty" class="text-center py-4 text-muted d-none">
|
|
<i class="bi bi-bell-slash fs-2 d-block mb-2"></i>No notifications have been sent for this invoice.
|
|
</div>
|
|
<div id="invNotifTable" class="d-none">
|
|
<table class="table table-sm table-hover mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th>Sent At</th>
|
|
<th>Type</th>
|
|
<th>Channel</th>
|
|
<th>Recipient</th>
|
|
<th>Subject</th>
|
|
<th>Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="invNotifBody"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Issue Refund Modal -->
|
|
<div class="modal fade" id="issueRefundModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><i class="bi bi-arrow-counterclockwise me-2 text-danger"></i>Issue Refund</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<form asp-action="IssueRefund" asp-route-invoiceId="@Model.Id" method="post">
|
|
@Html.AntiForgeryToken()
|
|
<div class="modal-body">
|
|
<div id="refundAlertCash" class="alert alert-info alert-permanent small mb-3">
|
|
<i class="bi bi-info-circle me-1"></i>
|
|
This records the refund intent. You still need to issue the actual refund (cash, check, etc.) manually.
|
|
</div>
|
|
<div id="refundAlertCredit" class="alert alert-success small mb-3 d-none">
|
|
<i class="bi bi-piggy-bank me-1"></i>
|
|
The refund amount will be added to the customer's store credit balance immediately — no manual action needed.
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label fw-semibold">Amount <span class="text-danger">*</span></label>
|
|
<div class="input-group">
|
|
<span class="input-group-text">$</span>
|
|
<input type="number" name="Amount" class="form-control" step="0.01" min="0.01"
|
|
max="@Model.AmountPaid.ToString("F2")" value="@Model.AmountPaid.ToString("F2")" required />
|
|
</div>
|
|
<div class="form-text">Amount paid: @Model.AmountPaid.ToString("C")</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label fw-semibold">Refund Date <span class="text-danger">*</span></label>
|
|
<input type="date" name="RefundDate" class="form-control"
|
|
value="@DateTime.Today.ToString("yyyy-MM-dd")" required />
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label fw-semibold">Refund Method <span class="text-danger">*</span></label>
|
|
<select name="RefundMethod" id="refundMethodSelect" class="form-select" required>
|
|
<option value="0">Cash</option>
|
|
<option value="1">Check</option>
|
|
<option value="2">Credit/Debit Card</option>
|
|
<option value="3">Bank Transfer / ACH</option>
|
|
<option value="4">Digital Payment (Venmo/PayPal/Zelle)</option>
|
|
<option value="5">Store Credit</option>
|
|
</select>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label fw-semibold">Reason <span class="text-danger">*</span></label>
|
|
<input type="text" name="Reason" class="form-control" placeholder="e.g. Warranty claim, duplicate charge..." required />
|
|
</div>
|
|
<div class="mb-3" id="refundReferenceRow">
|
|
<label class="form-label">Reference <small class="text-muted">(check #, transaction ID)</small></label>
|
|
<input type="text" name="Reference" class="form-control" placeholder="Optional" />
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Notes</label>
|
|
<textarea name="Notes" class="form-control" rows="2" placeholder="Optional"></textarea>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="submit" class="btn btn-danger">
|
|
<i class="bi bi-arrow-counterclockwise me-2"></i>Record Refund
|
|
</button>
|
|
</div>
|
|
</form>
|
|
<script>
|
|
document.getElementById('refundMethodSelect').addEventListener('change', function () {
|
|
var isCredit = this.value === '5';
|
|
document.getElementById('refundAlertCash').classList.toggle('d-none', isCredit);
|
|
document.getElementById('refundAlertCredit').classList.toggle('d-none', !isCredit);
|
|
document.getElementById('refundReferenceRow').classList.toggle('d-none', isCredit);
|
|
});
|
|
</script>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Issue Credit Memo Modal -->
|
|
<div class="modal fade" id="issueCreditModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><i class="bi bi-gift me-2 text-info"></i>Issue Credit Memo</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<form asp-action="IssueCreditMemo" asp-route-invoiceId="@Model.Id" method="post">
|
|
@Html.AntiForgeryToken()
|
|
<div class="modal-body">
|
|
<div class="alert alert-info alert-permanent small mb-3">
|
|
<i class="bi bi-info-circle me-1"></i>
|
|
A credit memo adds store credit to the customer's account that can be applied against future invoices.
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label fw-semibold">Credit Amount <span class="text-danger">*</span></label>
|
|
<div class="input-group">
|
|
<span class="input-group-text">$</span>
|
|
<input type="number" name="Amount" class="form-control" step="0.01" min="0.01" required />
|
|
</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label fw-semibold">Reason <span class="text-danger">*</span></label>
|
|
<input type="text" name="Reason" class="form-control" placeholder="e.g. Warranty resolution, billing correction..." required />
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Expiry Date <small class="text-muted">(optional)</small></label>
|
|
<input type="date" name="ExpiryDate" class="form-control" />
|
|
<div class="form-text">Leave blank for no expiry.</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Notes</label>
|
|
<textarea name="Notes" class="form-control" rows="2" placeholder="Optional"></textarea>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="submit" class="btn btn-info text-white">
|
|
<i class="bi bi-gift me-2"></i>Issue Credit Memo
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
@if (canApplyCredit)
|
|
{
|
|
<!-- Apply Credit Modal -->
|
|
<div class="modal fade" id="applyCreditModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><i class="bi bi-check2-circle me-2 text-success"></i>Apply Credit</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<form asp-action="ApplyCredit" asp-route-invoiceId="@Model.Id" method="post">
|
|
@Html.AntiForgeryToken()
|
|
<div class="modal-body">
|
|
<div class="mb-3">
|
|
<label class="form-label fw-semibold">Select Credit Memo <span class="text-danger">*</span></label>
|
|
<select name="CreditMemoId" class="form-select" required>
|
|
<option value="">— Select —</option>
|
|
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.AvailableCreditMemos)
|
|
{
|
|
<option value="@item.Value">@item.Text</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label fw-semibold">Amount to Apply <span class="text-danger">*</span></label>
|
|
<div class="input-group">
|
|
<span class="input-group-text">$</span>
|
|
<input type="number" name="Amount" class="form-control" step="0.01" min="0.01"
|
|
max="@Model.BalanceDue.ToString("F2")" value="@Model.BalanceDue.ToString("F2")" required />
|
|
</div>
|
|
<div class="form-text">Balance due: @Model.BalanceDue.ToString("C") — the system will cap at the memo's remaining balance.</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="submit" class="btn btn-success">
|
|
<i class="bi bi-check2-circle me-2"></i>Apply Credit
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<!-- Apply Gift Certificate Modal -->
|
|
@if (canPay)
|
|
{
|
|
<div class="modal fade" id="applyGiftCertModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><i class="bi bi-gift me-2 text-success"></i>Apply Gift Certificate</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<form asp-action="ApplyGiftCertificate" asp-route-invoiceId="@Model.Id" method="post">
|
|
@Html.AntiForgeryToken()
|
|
<div class="modal-body">
|
|
<div class="mb-3">
|
|
<label class="form-label fw-semibold">Certificate Code <span class="text-danger">*</span></label>
|
|
<div class="input-group">
|
|
<input type="text" name="CertificateCode" id="gcCodeInput" class="form-control font-monospace text-uppercase"
|
|
placeholder="GC-2503-0001" required maxlength="20" oninput="lookupGiftCert()" />
|
|
<span class="input-group-text" id="gcLookupSpinner" style="display:none;">
|
|
<span class="spinner-border spinner-border-sm"></span>
|
|
</span>
|
|
</div>
|
|
<div id="gcLookupResult" class="mt-2"></div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label fw-semibold">Amount to Apply <span class="text-danger">*</span></label>
|
|
<div class="input-group">
|
|
<span class="input-group-text">$</span>
|
|
<input type="number" name="Amount" id="gcAmountInput" class="form-control" step="0.01" min="0.01"
|
|
max="@Model.BalanceDue.ToString("F2")" value="@Model.BalanceDue.ToString("F2")" required />
|
|
</div>
|
|
<div class="form-text">Balance due: @Model.BalanceDue.ToString("C")</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="submit" class="btn btn-success">
|
|
<i class="bi bi-gift me-2"></i>Apply Certificate
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<!-- Write-Off Modal -->
|
|
@if (!isVoided && Model.BalanceDue > 0)
|
|
{
|
|
<div class="modal fade" id="writeOffModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><i class="bi bi-journal-x me-2 text-danger"></i>Write Off Invoice</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<form asp-action="WriteOff" asp-route-id="@Model.Id" method="post">
|
|
@Html.AntiForgeryToken()
|
|
<div class="modal-body">
|
|
<div class="alert alert-warning alert-permanent py-2 mb-3">
|
|
<i class="bi bi-exclamation-triangle me-2"></i>
|
|
This will write off the remaining balance of <strong>@Model.BalanceDue.ToString("C")</strong>
|
|
as bad debt. A GL journal entry will be posted. This action cannot be undone.
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Bad Debt Expense Account</label>
|
|
<select name="expenseAccountId" class="form-select">
|
|
<option value="">— Use default bad debt account —</option>
|
|
@if (ViewBag.ExpenseAccounts != null)
|
|
{
|
|
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.ExpenseAccounts)
|
|
{
|
|
<option value="@item.Value">@item.Text</option>
|
|
}
|
|
}
|
|
</select>
|
|
<div class="form-text">If blank, the system selects the first account with "bad" or "debt" in the name, or falls back to the first expense account.</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Notes</label>
|
|
<textarea name="notes" class="form-control" rows="2" placeholder="Reason for write-off (optional)"></textarea>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="submit" class="btn btn-danger">
|
|
<i class="bi bi-journal-x me-2"></i>Write Off @Model.BalanceDue.ToString("C")
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@section Scripts {
|
|
<script>
|
|
function openEditPaymentModal(paymentId, invoiceId, paymentDate, paymentMethod, reference, notes, depositAccountId) {
|
|
document.getElementById('editPaymentId').value = paymentId;
|
|
document.getElementById('editPaymentDate').value = paymentDate;
|
|
document.getElementById('editPaymentMethod').value = paymentMethod;
|
|
document.getElementById('editPaymentReference').value = reference;
|
|
document.getElementById('editPaymentNotes').value = notes;
|
|
const acctSelect = document.getElementById('editPaymentDepositAccount');
|
|
acctSelect.value = depositAccountId != null ? String(depositAccountId) : '';
|
|
new bootstrap.Modal(document.getElementById('editPaymentModal')).show();
|
|
}
|
|
|
|
let gcLookupTimer;
|
|
function lookupGiftCert() {
|
|
clearTimeout(gcLookupTimer);
|
|
const code = document.getElementById('gcCodeInput').value.trim();
|
|
const result = document.getElementById('gcLookupResult');
|
|
if (code.length < 5) { result.innerHTML = ''; return; }
|
|
gcLookupTimer = setTimeout(async () => {
|
|
document.getElementById('gcLookupSpinner').style.display = '';
|
|
result.innerHTML = '';
|
|
try {
|
|
const resp = await fetch(`/Invoices/LookupGiftCertificate?code=${encodeURIComponent(code)}`);
|
|
const data = await resp.json();
|
|
if (!data.found) {
|
|
result.innerHTML = '<div class="alert alert-warning py-1 mb-0 small">Certificate not found.</div>';
|
|
} else if (data.status === 'Voided' || data.status === 'FullyRedeemed' || data.status === 'Expired') {
|
|
result.innerHTML = `<div class="alert alert-danger py-1 mb-0 small">Certificate is ${data.status} and cannot be applied.</div>`;
|
|
} else {
|
|
const max = Math.min(data.remainingBalance, @Model.BalanceDue.ToString("F2", System.Globalization.CultureInfo.InvariantCulture));
|
|
document.getElementById('gcAmountInput').value = max.toFixed(2);
|
|
document.getElementById('gcAmountInput').max = max;
|
|
const expiry = data.expiryDate ? ` · Expires ${data.expiryDate}` : '';
|
|
result.innerHTML = `<div class="alert alert-success py-1 mb-0 small"><i class="bi bi-check-circle me-1"></i><strong>${data.certificateCode}</strong> — $${data.remainingBalance.toFixed(2)} remaining${expiry}</div>`;
|
|
}
|
|
} catch { result.innerHTML = '<div class="alert alert-danger py-1 mb-0 small">Lookup failed.</div>'; }
|
|
document.getElementById('gcLookupSpinner').style.display = 'none';
|
|
}, 400);
|
|
}
|
|
|
|
function sendToAdHocEmail(invoiceId) {
|
|
const email = (document.getElementById('adHocEmailInput').value ?? '').trim();
|
|
const errDiv = document.getElementById('adHocEmailError');
|
|
if (!email || !email.includes('@@')) {
|
|
errDiv.textContent = 'Please enter a valid email address.';
|
|
errDiv.classList.remove('d-none');
|
|
return;
|
|
}
|
|
errDiv.classList.add('d-none');
|
|
bootstrap.Modal.getInstance(document.getElementById('sendToAdHocEmailModal'))?.hide();
|
|
|
|
const mode = document.getElementById('adHocEmailMode')?.value ?? 'resend';
|
|
if (mode === 'send') {
|
|
document.getElementById('sendInvoiceOverrideEmail').value = email;
|
|
document.getElementById('sendInvoiceForm').submit();
|
|
} else {
|
|
resendInvoice(invoiceId, email);
|
|
}
|
|
}
|
|
|
|
function resendInvoice(invoiceId, overrideEmail) {
|
|
document.getElementById('resendInvoiceSending').classList.remove('d-none');
|
|
document.getElementById('resendInvoiceResult').classList.add('d-none');
|
|
document.getElementById('resendInvoiceFooter').classList.add('d-none');
|
|
document.getElementById('resendInvoiceModalHeader').className = 'modal-header';
|
|
|
|
const modal = new bootstrap.Modal(document.getElementById('resendInvoiceModal'));
|
|
modal.show();
|
|
|
|
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
|
let url = '@Url.Action("ResendInvoice", "Invoices")?id=' + invoiceId;
|
|
if (overrideEmail) url += '&overrideEmail=' + encodeURIComponent(overrideEmail);
|
|
|
|
fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'RequestVerificationToken': token, 'X-Requested-With': 'XMLHttpRequest' }
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
document.getElementById('resendInvoiceSending').classList.add('d-none');
|
|
document.getElementById('resendInvoiceResult').classList.remove('d-none');
|
|
document.getElementById('resendInvoiceFooter').classList.remove('d-none');
|
|
|
|
const icon = document.getElementById('resendInvoiceIcon');
|
|
const msg = document.getElementById('resendInvoiceMessage');
|
|
const header = document.getElementById('resendInvoiceModalHeader');
|
|
|
|
if (data.success) {
|
|
icon.className = 'bi bi-check-circle-fill text-success fs-1 d-block mb-3';
|
|
header.className = 'modal-header bg-success text-white';
|
|
showInfo(data.message, 'Email Sent');
|
|
} else {
|
|
icon.className = 'bi bi-x-circle-fill text-danger fs-1 d-block mb-3';
|
|
header.className = 'modal-header bg-danger text-white';
|
|
showWarning(data.message, 'Email Not Sent');
|
|
}
|
|
msg.textContent = data.message;
|
|
})
|
|
.catch(() => {
|
|
document.getElementById('resendInvoiceSending').classList.add('d-none');
|
|
document.getElementById('resendInvoiceResult').classList.remove('d-none');
|
|
document.getElementById('resendInvoiceFooter').classList.remove('d-none');
|
|
document.getElementById('resendInvoiceIcon').className = 'bi bi-x-circle-fill text-danger fs-1 d-block mb-3';
|
|
document.getElementById('resendInvoiceModalHeader').className = 'modal-header bg-danger text-white';
|
|
document.getElementById('resendInvoiceMessage').textContent = 'A network error occurred. Please try again.';
|
|
});
|
|
}
|
|
|
|
function loadInvoiceNotifications(invoiceId) {
|
|
const modal = new bootstrap.Modal(document.getElementById('invoiceNotificationsModal'));
|
|
document.getElementById('invNotifLoading').classList.remove('d-none');
|
|
document.getElementById('invNotifEmpty').classList.add('d-none');
|
|
document.getElementById('invNotifTable').classList.add('d-none');
|
|
modal.show();
|
|
|
|
fetch('@Url.Action("NotificationsSent", "Invoices")?id=' + invoiceId)
|
|
.then(r => r.json())
|
|
.then(logs => {
|
|
document.getElementById('invNotifLoading').classList.add('d-none');
|
|
if (!logs.length) {
|
|
document.getElementById('invNotifEmpty').classList.remove('d-none');
|
|
return;
|
|
}
|
|
const tbody = document.getElementById('invNotifBody');
|
|
tbody.innerHTML = logs.map((n, i) => {
|
|
const statusClass = n.status === 'Sent' ? 'success' : n.status === 'Failed' ? 'danger' : 'secondary';
|
|
const channelIcon = n.channel === 'Email' ? 'bi-envelope' : 'bi-phone';
|
|
const errorId = `invNotifErr_${i}`;
|
|
const hasError = !!n.errorMessage;
|
|
const canExpand = n.status !== 'Sent';
|
|
const expandBtn = canExpand
|
|
? ` <button class="btn btn-link btn-sm p-0 ms-1 text-danger" style="font-size:.75rem;vertical-align:baseline;"
|
|
onclick="document.getElementById('${errorId}').classList.toggle('d-none')">
|
|
details
|
|
</button>`
|
|
: '';
|
|
const errorRow = canExpand
|
|
? `<tr id="${errorId}" class="d-none"><td colspan="6" class="text-danger small ps-3 pb-2 border-0">
|
|
<i class="bi bi-exclamation-triangle me-1"></i>${escHtml(n.errorMessage || n.message || 'No additional details available.')}
|
|
</td></tr>`
|
|
: '';
|
|
return `<tr>
|
|
<td class="text-nowrap small">${escHtml(n.sentAt)}</td>
|
|
<td class="small">${escHtml(n.type.replace(/([A-Z])/g, ' $1').trim())}</td>
|
|
<td class="small"><i class="bi ${channelIcon} me-1"></i>${escHtml(n.channel)}</td>
|
|
<td class="small">${escHtml(n.recipientName)}<br><span class="text-muted">${escHtml(n.recipient)}</span></td>
|
|
<td class="small">${n.subject ? escHtml(n.subject) : '<span class="text-muted">—</span>'}</td>
|
|
<td><span class="badge bg-${statusClass}">${escHtml(n.status)}</span>${expandBtn}</td>
|
|
</tr>${errorRow}`;
|
|
}).join('');
|
|
document.getElementById('invNotifTable').classList.remove('d-none');
|
|
})
|
|
.catch(() => {
|
|
document.getElementById('invNotifLoading').classList.add('d-none');
|
|
document.getElementById('invNotifBody').innerHTML =
|
|
'<tr><td colspan="6" class="text-danger">Failed to load notifications.</td></tr>';
|
|
document.getElementById('invNotifTable').classList.remove('d-none');
|
|
});
|
|
}
|
|
|
|
function escHtml(str) {
|
|
if (!str) return '';
|
|
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
function copyPaymentLink() {
|
|
const input = document.getElementById('paymentLinkInput');
|
|
if (!input) return;
|
|
navigator.clipboard.writeText(input.value).then(() => {
|
|
const icon = document.getElementById('copyIcon');
|
|
icon.className = 'bi bi-check2';
|
|
setTimeout(() => icon.className = 'bi bi-clipboard', 2000);
|
|
});
|
|
}
|
|
|
|
async function regeneratePaymentLink(invoiceId) {
|
|
const btn = event.currentTarget;
|
|
const msg = document.getElementById('onlinePaymentMsg');
|
|
btn.disabled = true;
|
|
if (msg) msg.innerHTML = '<span class="text-muted small">Generating...</span>';
|
|
|
|
const token = document.querySelector('input[name="__RequestVerificationToken"]').value;
|
|
try {
|
|
const resp = await fetch(`/Invoices/RegeneratePaymentLink?id=${invoiceId}`, {
|
|
method: 'POST',
|
|
headers: { 'RequestVerificationToken': token }
|
|
});
|
|
const data = await resp.json();
|
|
if (data.success) {
|
|
if (msg) msg.innerHTML = `
|
|
<div class="alert alert-success alert-permanent py-2 small">
|
|
<i class="bi bi-check-circle me-1"></i>New link generated!
|
|
<a href="${data.paymentUrl}" target="_blank" class="alert-link ms-1">Open</a>
|
|
</div>
|
|
<div class="input-group input-group-sm mt-1">
|
|
<input type="text" class="form-control font-monospace" value="${data.paymentUrl}" readonly onclick="this.select()" />
|
|
<button class="btn btn-outline-secondary" type="button"
|
|
onclick="navigator.clipboard.writeText('${data.paymentUrl}')">
|
|
<i class="bi bi-clipboard"></i>
|
|
</button>
|
|
</div>`;
|
|
} else {
|
|
if (msg) msg.innerHTML = `<div class="alert alert-warning py-2 small">${escHtml(data.message)}</div>`;
|
|
}
|
|
} catch {
|
|
if (msg) msg.innerHTML = '<div class="alert alert-danger py-2 small">An error occurred.</div>';
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
</script>
|
|
}
|