Files
PowderCoatingLogix/src/PowderCoating.Web/Views/Invoices/Details.cshtml
T
spouliot 328b195127 Design consistency audit fixes: alerts, cards, dark mode, utilities
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>
2026-05-10 18:05:29 -04:00

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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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>
}