Fix invoice re-creation after void; add payment terms selector and shop supplies line
- Voided invoices no longer block creating a new invoice for the same job: voided invoice's JobId FK is cleared so the unique index slot is freed for the replacement - Invoice Details view shows voided invoices as history rather than hiding them - Payment terms: standardized SelectList (Due on Receipt, Net 15/30/45/60/90, 2% 10 Net 30, COD) with custom-term preservation; invoice-due-date.js auto-updates Due Date on term change - Shop supplies on direct (no-quote) jobs: InvoicesController derives the shop supplies line from the company rate when the job has no source quote to read the pre-agreed amount from - Job entity: ShopSuppliesAmount + ShopSuppliesPercent fields preserved through job lifecycle - Migration: AddShopSuppliesAmountToJob Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,12 @@ public class JobDto
|
|||||||
|
|
||||||
public decimal QuotedPrice { get; set; }
|
public decimal QuotedPrice { get; set; }
|
||||||
public decimal FinalPrice { get; set; }
|
public decimal FinalPrice { get; set; }
|
||||||
|
public decimal ShopSuppliesAmount { get; set; }
|
||||||
|
public decimal ShopSuppliesPercent { get; set; }
|
||||||
|
public bool IsRushJob { get; set; }
|
||||||
|
public string DiscountType { get; set; } = "None";
|
||||||
|
public decimal DiscountValue { get; set; }
|
||||||
|
public string? DiscountReason { get; set; }
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
public string? SpecialInstructions { get; set; }
|
public string? SpecialInstructions { get; set; }
|
||||||
public string? InternalNotes { get; set; }
|
public string? InternalNotes { get; set; }
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ public class InvoiceProfile : Profile
|
|||||||
? s.Customer.CompanyName
|
? s.Customer.CompanyName
|
||||||
: $"{s.Customer.ContactFirstName} {s.Customer.ContactLastName}".Trim())
|
: $"{s.Customer.ContactFirstName} {s.Customer.ContactLastName}".Trim())
|
||||||
: string.Empty))
|
: string.Empty))
|
||||||
.ForMember(d => d.CustomerEmail, o => o.MapFrom(s => s.Customer != null ? s.Customer.Email : null))
|
.ForMember(d => d.CustomerEmail, o => o.MapFrom(s => s.Customer != null
|
||||||
|
? (s.Customer.BillingEmail ?? s.Customer.Email)
|
||||||
|
: null))
|
||||||
.ForMember(d => d.CustomerPhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.Phone : null))
|
.ForMember(d => d.CustomerPhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.Phone : null))
|
||||||
.ForMember(d => d.CustomerNotifyByEmail, o => o.MapFrom(s => s.Customer == null || s.Customer.NotifyByEmail))
|
.ForMember(d => d.CustomerNotifyByEmail, o => o.MapFrom(s => s.Customer == null || s.Customer.NotifyByEmail))
|
||||||
.ForMember(d => d.PreparedByName, o => o.MapFrom(s => s.PreparedBy != null
|
.ForMember(d => d.PreparedByName, o => o.MapFrom(s => s.PreparedBy != null
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ public class Job : BaseEntity
|
|||||||
// Pricing
|
// Pricing
|
||||||
public decimal QuotedPrice { get; set; }
|
public decimal QuotedPrice { get; set; }
|
||||||
public decimal FinalPrice { get; set; }
|
public decimal FinalPrice { get; set; }
|
||||||
|
public decimal ShopSuppliesAmount { get; set; }
|
||||||
|
public decimal ShopSuppliesPercent { get; set; }
|
||||||
|
|
||||||
// Discount & rush (mirrors quote fields; preserved through quote→job conversion and job edits)
|
// Discount & rush (mirrors quote fields; preserved through quote→job conversion and job edits)
|
||||||
public bool IsRushJob { get; set; }
|
public bool IsRushJob { get; set; }
|
||||||
|
|||||||
Generated
+9546
File diff suppressed because it is too large
Load Diff
+83
@@ -0,0 +1,83 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddShopSuppliesAmountToJob : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "ShopSuppliesAmount",
|
||||||
|
table: "Jobs",
|
||||||
|
type: "decimal(18,2)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "ShopSuppliesPercent",
|
||||||
|
table: "Jobs",
|
||||||
|
type: "decimal(18,2)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4358));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4424));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4426));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ShopSuppliesAmount",
|
||||||
|
table: "Jobs");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ShopSuppliesPercent",
|
||||||
|
table: "Jobs");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 8, 1, 37, 3, 534, DateTimeKind.Utc).AddTicks(1857));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 8, 1, 37, 3, 534, DateTimeKind.Utc).AddTicks(1863));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 8, 1, 37, 3, 534, DateTimeKind.Utc).AddTicks(1865));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,30 @@ public class InvoicesController : Controller
|
|||||||
_logoService = logoService;
|
_logoService = logoService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static readonly string[] StandardPaymentTerms =
|
||||||
|
[
|
||||||
|
"Due on Receipt",
|
||||||
|
"Net 15",
|
||||||
|
"Net 30",
|
||||||
|
"Net 45",
|
||||||
|
"Net 60",
|
||||||
|
"Net 90",
|
||||||
|
"2% 10 Net 30",
|
||||||
|
"COD",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the payment terms SelectList for Create/Edit views. Always includes the provided
|
||||||
|
/// <paramref name="selectedTerm"/> even if it is a custom value not in the standard list.
|
||||||
|
/// </summary>
|
||||||
|
private static SelectList BuildPaymentTermsSelectList(string? selectedTerm)
|
||||||
|
{
|
||||||
|
var terms = StandardPaymentTerms.ToList();
|
||||||
|
if (!string.IsNullOrWhiteSpace(selectedTerm) && !terms.Contains(selectedTerm, StringComparer.OrdinalIgnoreCase))
|
||||||
|
terms.Insert(0, selectedTerm);
|
||||||
|
return new SelectList(terms, selectedTerm);
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// GET: /Invoices
|
// GET: /Invoices
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -328,9 +352,9 @@ public class InvoicesController : Controller
|
|||||||
var job = await _unitOfWork.Jobs.GetByIdAsync(jobId.Value, false, j => j.Customer, j => j.JobItems);
|
var job = await _unitOfWork.Jobs.GetByIdAsync(jobId.Value, false, j => j.Customer, j => j.JobItems);
|
||||||
if (job == null) return NotFound();
|
if (job == null) return NotFound();
|
||||||
|
|
||||||
// Validate no existing invoice for this job
|
// Validate no existing active invoice for this job (voided ones are kept as history)
|
||||||
var existing = await _unitOfWork.Invoices.GetForJobAsync(jobId.Value, includeDeleted: true);
|
var existing = await _unitOfWork.Invoices.GetForJobAsync(jobId.Value);
|
||||||
if (existing != null)
|
if (existing != null && existing.Status != InvoiceStatus.Voided)
|
||||||
return RedirectToAction(nameof(Details), new { id = existing.Id });
|
return RedirectToAction(nameof(Details), new { id = existing.Id });
|
||||||
|
|
||||||
dto.JobId = job.Id;
|
dto.JobId = job.Id;
|
||||||
@@ -383,12 +407,15 @@ public class InvoicesController : Controller
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track whether there were real job items before any fallback
|
||||||
|
bool hadJobItems = dto.InvoiceItems.Any();
|
||||||
|
|
||||||
// If no job items, use job final price as single line.
|
// If no job items, use job final price as single line.
|
||||||
// FinalPrice is always the post-tax total (set by the pricing engine or imported from
|
// FinalPrice is always the post-tax total (set by the pricing engine or imported from
|
||||||
// an export). Treat it as the agreed total and force TaxPercent = 0 so the invoice
|
// an export). Treat it as the agreed total and force TaxPercent = 0 so the invoice
|
||||||
// does not apply tax a second time. Without this, imported jobs double-tax because
|
// does not apply tax a second time. Without this, imported jobs double-tax because
|
||||||
// their FinalPrice already includes the tax that was applied in the source environment.
|
// their FinalPrice already includes the tax that was applied in the source environment.
|
||||||
if (!dto.InvoiceItems.Any())
|
if (!hadJobItems)
|
||||||
{
|
{
|
||||||
var defaultRevAccId = defaultRevenueAccount?.Id;
|
var defaultRevAccId = defaultRevenueAccount?.Id;
|
||||||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||||
@@ -431,6 +458,26 @@ public class InvoicesController : Controller
|
|||||||
dto.TaxPercent = sourceQuote.TaxPercent;
|
dto.TaxPercent = sourceQuote.TaxPercent;
|
||||||
dto.DiscountAmount = sourceQuote.DiscountAmount;
|
dto.DiscountAmount = sourceQuote.DiscountAmount;
|
||||||
}
|
}
|
||||||
|
else if (hadJobItems && costs?.ShopSuppliesRate > 0)
|
||||||
|
{
|
||||||
|
// Direct job — no source quote. Derive shop supplies from the items subtotal
|
||||||
|
// using the current company rate. (Quote-sourced jobs read the pre-agreed amount
|
||||||
|
// from the quote snapshot instead; this path only fires when there is no quote.)
|
||||||
|
var itemsSubtotal = dto.InvoiceItems.Sum(i => i.TotalPrice);
|
||||||
|
var shopSuppliesAmount = Math.Round(itemsSubtotal * (costs.ShopSuppliesRate / 100m), 2);
|
||||||
|
if (shopSuppliesAmount > 0.01m)
|
||||||
|
{
|
||||||
|
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||||
|
{
|
||||||
|
Description = $"Shop Supplies ({costs.ShopSuppliesRate:0.##}%)",
|
||||||
|
Quantity = 1,
|
||||||
|
UnitPrice = shopSuppliesAmount,
|
||||||
|
TotalPrice = shopSuppliesAmount,
|
||||||
|
DisplayOrder = order,
|
||||||
|
RevenueAccountId = defaultRevenueAccount?.Id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Override tax to 0 for tax-exempt customers, regardless of company default or quote rate
|
// Override tax to 0 for tax-exempt customers, regardless of company default or quote rate
|
||||||
if (job.Customer?.IsTaxExempt == true)
|
if (job.Customer?.IsTaxExempt == true)
|
||||||
@@ -444,7 +491,7 @@ public class InvoicesController : Controller
|
|||||||
: string.Empty;
|
: string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
await PopulateCreateViewBagAsync(currentUser.CompanyId, dto.Terms);
|
||||||
ViewBag.GuidedActivation = guidedActivation;
|
ViewBag.GuidedActivation = guidedActivation;
|
||||||
return View(dto);
|
return View(dto);
|
||||||
}
|
}
|
||||||
@@ -485,7 +532,7 @@ public class InvoicesController : Controller
|
|||||||
|
|
||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
{
|
{
|
||||||
await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
await PopulateCreateViewBagAsync(currentUser.CompanyId, dto.Terms);
|
||||||
ViewBag.GuidedActivation = guidedActivation;
|
ViewBag.GuidedActivation = guidedActivation;
|
||||||
return View(dto);
|
return View(dto);
|
||||||
}
|
}
|
||||||
@@ -493,21 +540,32 @@ public class InvoicesController : Controller
|
|||||||
if (!dto.InvoiceItems.Any())
|
if (!dto.InvoiceItems.Any())
|
||||||
{
|
{
|
||||||
ModelState.AddModelError("", "Please add at least one line item before saving.");
|
ModelState.AddModelError("", "Please add at least one line item before saving.");
|
||||||
await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
await PopulateCreateViewBagAsync(currentUser.CompanyId, dto.Terms);
|
||||||
ViewBag.GuidedActivation = guidedActivation;
|
ViewBag.GuidedActivation = guidedActivation;
|
||||||
return View(dto);
|
return View(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate no existing invoice for this job before starting the transaction
|
// Validate no existing active invoice for this job before starting the transaction.
|
||||||
|
// Voided invoices are treated as history — clear their JobId FK so the unique index
|
||||||
|
// slot is freed and the new invoice can be saved.
|
||||||
if (dto.JobId.HasValue)
|
if (dto.JobId.HasValue)
|
||||||
{
|
{
|
||||||
var existing = await _unitOfWork.Invoices.GetForJobAsync(dto.JobId.Value, includeDeleted: true);
|
var existing = await _unitOfWork.Invoices.GetForJobAsync(dto.JobId.Value);
|
||||||
if (existing != null)
|
if (existing != null)
|
||||||
{
|
{
|
||||||
ModelState.AddModelError("", "An invoice already exists for this job.");
|
if (existing.Status != InvoiceStatus.Voided)
|
||||||
await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
{
|
||||||
ViewBag.GuidedActivation = guidedActivation;
|
ModelState.AddModelError("", "An invoice already exists for this job.");
|
||||||
return View(dto);
|
await PopulateCreateViewBagAsync(currentUser.CompanyId, dto.Terms);
|
||||||
|
ViewBag.GuidedActivation = guidedActivation;
|
||||||
|
return View(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the voided invoice's JobId so the unique (CompanyId, JobId) index
|
||||||
|
// allows the new invoice to be inserted.
|
||||||
|
existing.JobId = null;
|
||||||
|
await _unitOfWork.Invoices.UpdateAsync(existing);
|
||||||
|
await _unitOfWork.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -684,7 +742,7 @@ public class InvoicesController : Controller
|
|||||||
_logger.LogError(ex, "Error creating invoice");
|
_logger.LogError(ex, "Error creating invoice");
|
||||||
TempData["Error"] = "An error occurred while creating the invoice.";
|
TempData["Error"] = "An error occurred while creating the invoice.";
|
||||||
var currentUser = await _userManager.GetUserAsync(User);
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
if (currentUser != null) await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
if (currentUser != null) await PopulateCreateViewBagAsync(currentUser.CompanyId, dto.Terms);
|
||||||
ViewBag.GuidedActivation = guidedActivation;
|
ViewBag.GuidedActivation = guidedActivation;
|
||||||
return View(dto);
|
return View(dto);
|
||||||
}
|
}
|
||||||
@@ -694,9 +752,9 @@ public class InvoicesController : Controller
|
|||||||
// GET: /Invoices/Edit/5
|
// GET: /Invoices/Edit/5
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Loads the Edit form. Only Draft invoices are editable — any other status redirects to
|
/// Loads the Edit form. Draft, Sent, and Overdue invoices are editable. Paid, PartiallyPaid,
|
||||||
/// Details with an error. Sent/Paid/Voided invoices must be voided and recreated rather
|
/// Voided, and WrittenOff invoices are locked — those statuses represent committed financial
|
||||||
/// than edited, to preserve the audit trail for those states.
|
/// records that should not be altered after the fact.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<IActionResult> Edit(int? id)
|
public async Task<IActionResult> Edit(int? id)
|
||||||
{
|
{
|
||||||
@@ -707,9 +765,9 @@ public class InvoicesController : Controller
|
|||||||
var invoice = await LoadInvoiceForViewAsync(id.Value);
|
var invoice = await LoadInvoiceForViewAsync(id.Value);
|
||||||
if (invoice == null) return NotFound();
|
if (invoice == null) return NotFound();
|
||||||
|
|
||||||
if (invoice.Status != InvoiceStatus.Draft)
|
if (invoice.Status is not (InvoiceStatus.Draft or InvoiceStatus.Sent or InvoiceStatus.Overdue))
|
||||||
{
|
{
|
||||||
TempData["Error"] = "Only Draft invoices can be edited.";
|
TempData["Error"] = "Only open invoices (Draft, Sent, Overdue) can be edited.";
|
||||||
return RedirectToAction(nameof(Details), new { id });
|
return RedirectToAction(nameof(Details), new { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -748,6 +806,11 @@ public class InvoicesController : Controller
|
|||||||
? invoice.Customer.CompanyName
|
? invoice.Customer.CompanyName
|
||||||
: $"{invoice.Customer.ContactFirstName} {invoice.Customer.ContactLastName}".Trim())
|
: $"{invoice.Customer.ContactFirstName} {invoice.Customer.ContactLastName}".Trim())
|
||||||
: string.Empty;
|
: string.Empty;
|
||||||
|
ViewBag.InvoiceStatus = invoice.Status;
|
||||||
|
var customerEmail = invoice.Customer?.BillingEmail ?? invoice.Customer?.Email;
|
||||||
|
ViewBag.CanResend = invoice.Status is (InvoiceStatus.Sent or InvoiceStatus.Overdue)
|
||||||
|
&& !string.IsNullOrWhiteSpace(customerEmail);
|
||||||
|
ViewBag.PaymentTermsOptions = BuildPaymentTermsSelectList(dto.Terms);
|
||||||
|
|
||||||
return View(dto);
|
return View(dto);
|
||||||
}
|
}
|
||||||
@@ -763,23 +826,23 @@ public class InvoicesController : Controller
|
|||||||
// POST: /Invoices/Edit/5
|
// POST: /Invoices/Edit/5
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Saves edits to a Draft invoice. Line items are replaced via a soft-delete-and-add cycle
|
/// Saves edits to an open invoice (Draft, Sent, or Overdue). Line items are replaced via a
|
||||||
/// (old items flagged IsDeleted, new items inserted) so the audit trail of what was originally
|
/// soft-delete-and-add cycle so the original items are preserved in the audit trail.
|
||||||
/// on the invoice is preserved in the database. Customer.CurrentBalance is adjusted by the
|
/// Customer.CurrentBalance is adjusted by the delta (newTotal − oldTotal). Status is kept
|
||||||
/// delta (newTotal − oldTotal) so outstanding AR stays accurate without recalculating from scratch.
|
/// as-is (Sent stays Sent) so the customer-facing record remains consistent. If resendToCustomer
|
||||||
/// Only Draft invoices can be edited; guard is checked on both GET and POST.
|
/// is true and the invoice is Sent/Overdue, a fresh PDF is emailed to the customer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost, ValidateAntiForgeryToken]
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> Edit(int id, UpdateInvoiceDto dto)
|
public async Task<IActionResult> Edit(int id, UpdateInvoiceDto dto, bool resendToCustomer = false)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var invoice = await LoadInvoiceForViewAsync(id);
|
var invoice = await LoadInvoiceForViewAsync(id);
|
||||||
if (invoice == null) return NotFound();
|
if (invoice == null) return NotFound();
|
||||||
|
|
||||||
if (invoice.Status != InvoiceStatus.Draft)
|
if (invoice.Status is not (InvoiceStatus.Draft or InvoiceStatus.Sent or InvoiceStatus.Overdue))
|
||||||
{
|
{
|
||||||
TempData["Error"] = "Only Draft invoices can be edited.";
|
TempData["Error"] = "Only open invoices (Draft, Sent, Overdue) can be edited.";
|
||||||
return RedirectToAction(nameof(Details), new { id });
|
return RedirectToAction(nameof(Details), new { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -789,6 +852,7 @@ public class InvoicesController : Controller
|
|||||||
ViewBag.InvoiceId = invoice.Id;
|
ViewBag.InvoiceId = invoice.Id;
|
||||||
ViewBag.JobNumber = invoice.Job?.JobNumber;
|
ViewBag.JobNumber = invoice.Job?.JobNumber;
|
||||||
ViewBag.CustomerName = invoice.Customer?.CompanyName;
|
ViewBag.CustomerName = invoice.Customer?.CompanyName;
|
||||||
|
ViewBag.PaymentTermsOptions = BuildPaymentTermsSelectList(dto.Terms);
|
||||||
return View(dto);
|
return View(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -862,6 +926,28 @@ public class InvoicesController : Controller
|
|||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
TempData["Success"] = "Invoice updated successfully.";
|
TempData["Success"] = "Invoice updated successfully.";
|
||||||
|
|
||||||
|
// Optionally re-send the updated invoice PDF to the customer
|
||||||
|
if (resendToCustomer && invoice.Status is (InvoiceStatus.Sent or InvoiceStatus.Overdue))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var currentUserForPdf = await _userManager.GetUserAsync(User);
|
||||||
|
var pdfBytes = await BuildInvoicePdfAsync(invoice, invoice.CompanyId);
|
||||||
|
string? paymentUrl = null;
|
||||||
|
if (!string.IsNullOrEmpty(invoice.PaymentLinkToken))
|
||||||
|
paymentUrl = $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}";
|
||||||
|
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf", paymentUrl);
|
||||||
|
var notifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id);
|
||||||
|
this.SetNotificationResultToast(notifLog);
|
||||||
|
}
|
||||||
|
catch (Exception notifyEx)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(notifyEx, "Re-send of updated invoice {Id} failed", id);
|
||||||
|
TempData["WarningPermanent"] = "Invoice saved, but re-sending the email failed. You can re-send manually from the invoice details.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return RedirectToAction(nameof(Details), new { id });
|
return RedirectToAction(nameof(Details), new { id });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -883,7 +969,7 @@ public class InvoicesController : Controller
|
|||||||
/// works identically in dev (localhost) and production without config changes.
|
/// works identically in dev (localhost) and production without config changes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost, ValidateAntiForgeryToken]
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> Send(int id)
|
public async Task<IActionResult> Send(int id, string? overrideEmail = null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -916,7 +1002,7 @@ public class InvoicesController : Controller
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser!.CompanyId);
|
var pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser!.CompanyId);
|
||||||
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf", paymentUrl);
|
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf", paymentUrl, overrideEmail: overrideEmail?.Trim());
|
||||||
pdfAndNotifSucceeded = true;
|
pdfAndNotifSucceeded = true;
|
||||||
}
|
}
|
||||||
catch (Exception notifyEx)
|
catch (Exception notifyEx)
|
||||||
@@ -1296,7 +1382,7 @@ public class InvoicesController : Controller
|
|||||||
/// <see cref="BuildInvoicePdfAsync"/> which fetches company branding, template settings,
|
/// <see cref="BuildInvoicePdfAsync"/> which fetches company branding, template settings,
|
||||||
/// and the full invoice DTO in one call, then hands off to IPdfService.
|
/// and the full invoice DTO in one call, then hands off to IPdfService.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<IActionResult> DownloadPdf(int? id)
|
public async Task<IActionResult> DownloadPdf(int? id, bool inline = false)
|
||||||
{
|
{
|
||||||
if (id == null) return NotFound();
|
if (id == null) return NotFound();
|
||||||
|
|
||||||
@@ -1309,7 +1395,17 @@ public class InvoicesController : Controller
|
|||||||
if (currentUser == null) return Unauthorized();
|
if (currentUser == null) return Unauthorized();
|
||||||
|
|
||||||
var pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser.CompanyId);
|
var pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser.CompanyId);
|
||||||
return File(pdfBytes, "application/pdf", $"Invoice-{invoice.InvoiceNumber}.pdf");
|
var fileName = $"Invoice-{invoice.InvoiceNumber}.pdf";
|
||||||
|
|
||||||
|
if (inline)
|
||||||
|
{
|
||||||
|
// Return with inline content-disposition so the browser renders the PDF
|
||||||
|
// in a new tab, enabling the native print dialog.
|
||||||
|
Response.Headers["Content-Disposition"] = $"inline; filename=\"{fileName}\"";
|
||||||
|
return File(pdfBytes, "application/pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
return File(pdfBytes, "application/pdf", fileName);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -1336,9 +1432,10 @@ public class InvoicesController : Controller
|
|||||||
var currentUser = await _userManager.GetUserAsync(User);
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
if (currentUser == null) return Unauthorized();
|
if (currentUser == null) return Unauthorized();
|
||||||
|
|
||||||
var existing = await _unitOfWork.Invoices.GetForJobAsync(jobId, includeDeleted: true);
|
var existing = await _unitOfWork.Invoices.GetForJobAsync(jobId);
|
||||||
|
|
||||||
if (existing != null)
|
// Voided invoices are kept as history — don't block creation of a new one
|
||||||
|
if (existing != null && existing.Status != InvoiceStatus.Voided)
|
||||||
return RedirectToAction(nameof(Details), new { id = existing.Id });
|
return RedirectToAction(nameof(Details), new { id = existing.Id });
|
||||||
|
|
||||||
return RedirectToAction(nameof(Create), new { jobId });
|
return RedirectToAction(nameof(Create), new { jobId });
|
||||||
@@ -1361,7 +1458,7 @@ public class InvoicesController : Controller
|
|||||||
/// Details view can show an inline toast with the delivery outcome.
|
/// Details view can show an inline toast with the delivery outcome.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost, ValidateAntiForgeryToken]
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> ResendInvoice(int id)
|
public async Task<IActionResult> ResendInvoice(int id, string? overrideEmail = null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -1375,11 +1472,21 @@ public class InvoicesController : Controller
|
|||||||
if (invoice.Status == InvoiceStatus.Voided || invoice.Status == InvoiceStatus.WrittenOff)
|
if (invoice.Status == InvoiceStatus.Voided || invoice.Status == InvoiceStatus.WrittenOff)
|
||||||
return Json(new { success = false, message = "Voided invoices cannot be resent." });
|
return Json(new { success = false, message = "Voided invoices cannot be resent." });
|
||||||
|
|
||||||
|
// Validate override email when provided
|
||||||
|
overrideEmail = overrideEmail?.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(overrideEmail) && !overrideEmail.Contains('@'))
|
||||||
|
return Json(new { success = false, message = "The email address provided is not valid." });
|
||||||
|
|
||||||
var currentUser = await _userManager.GetUserAsync(User);
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
var recipientName = invoice.Customer?.IsCommercial == true
|
var recipientName = invoice.Customer?.IsCommercial == true
|
||||||
? invoice.Customer.CompanyName ?? "Customer"
|
? invoice.Customer.CompanyName ?? "Customer"
|
||||||
: $"{invoice.Customer?.ContactFirstName} {invoice.Customer?.ContactLastName}".Trim();
|
: $"{invoice.Customer?.ContactFirstName} {invoice.Customer?.ContactLastName}".Trim();
|
||||||
var recipientEmail = invoice.Customer?.Email ?? string.Empty;
|
var recipientEmail = !string.IsNullOrWhiteSpace(overrideEmail)
|
||||||
|
? overrideEmail
|
||||||
|
: invoice.Customer?.BillingEmail ?? invoice.Customer?.Email ?? string.Empty;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(recipientEmail))
|
||||||
|
return Json(new { success = false, message = "No email address on file. Please provide an address to send to." });
|
||||||
|
|
||||||
byte[]? pdfBytes = null;
|
byte[]? pdfBytes = null;
|
||||||
string? pdfFilename = null;
|
string? pdfFilename = null;
|
||||||
@@ -1393,7 +1500,7 @@ public class InvoicesController : Controller
|
|||||||
_logger.LogWarning(pdfEx, "PDF generation failed during resend of invoice {Id}; sending without attachment", id);
|
_logger.LogWarning(pdfEx, "PDF generation failed during resend of invoice {Id}; sending without attachment", id);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, pdfFilename);
|
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, pdfFilename, overrideEmail: overrideEmail);
|
||||||
|
|
||||||
var latestLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id);
|
var latestLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id);
|
||||||
|
|
||||||
@@ -1403,7 +1510,7 @@ public class InvoicesController : Controller
|
|||||||
if (latestLog?.Status == NotificationStatus.Skipped)
|
if (latestLog?.Status == NotificationStatus.Skipped)
|
||||||
return Json(new { success = false, message = $"{recipientName} has email notifications disabled or no email address on file." });
|
return Json(new { success = false, message = $"{recipientName} has email notifications disabled or no email address on file." });
|
||||||
|
|
||||||
return Json(new { success = true, message = $"Invoice resent to {recipientName} ({recipientEmail})." });
|
return Json(new { success = true, message = $"Invoice sent to {recipientEmail}." });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -1500,6 +1607,10 @@ public class InvoicesController : Controller
|
|||||||
if (invoice.TaxAmount > 0)
|
if (invoice.TaxAmount > 0)
|
||||||
await _accountBalanceService.DebitAsync(invoice.SalesTaxAccountId, invoice.TaxAmount);
|
await _accountBalanceService.DebitAsync(invoice.SalesTaxAccountId, invoice.TaxAmount);
|
||||||
|
|
||||||
|
// Clear the JobId FK before soft-deleting so the unique index slot is freed
|
||||||
|
// and a new invoice can be created for the same job if needed.
|
||||||
|
invoice.JobId = null;
|
||||||
|
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||||
await _unitOfWork.Invoices.SoftDeleteAsync(id);
|
await _unitOfWork.Invoices.SoftDeleteAsync(id);
|
||||||
|
|
||||||
}); // end ExecuteInTransactionAsync
|
}); // end ExecuteInTransactionAsync
|
||||||
@@ -1754,7 +1865,7 @@ public class InvoicesController : Controller
|
|||||||
/// — Company default tax rate and set of tax-exempt customer IDs for client-side JS to auto-zero tax.
|
/// — Company default tax rate and set of tax-exempt customer IDs for client-side JS to auto-zero tax.
|
||||||
/// — Merchandise catalog items serialized as camelCase JSON for the invoice line-item picker modal.
|
/// — Merchandise catalog items serialized as camelCase JSON for the invoice line-item picker modal.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task PopulateCreateViewBagAsync(int companyId)
|
private async Task PopulateCreateViewBagAsync(int companyId, string? selectedTerms = null)
|
||||||
{
|
{
|
||||||
var customers = await _unitOfWork.Customers.GetAllAsync();
|
var customers = await _unitOfWork.Customers.GetAllAsync();
|
||||||
ViewBag.Customers = customers.Where(c => c.IsActive).OrderBy(c => c.CompanyName ?? c.ContactLastName).ToList();
|
ViewBag.Customers = customers.Where(c => c.IsActive).OrderBy(c => c.CompanyName ?? c.ContactLastName).ToList();
|
||||||
@@ -1768,6 +1879,12 @@ public class InvoicesController : Controller
|
|||||||
.Select(c => c.Id)
|
.Select(c => c.Id)
|
||||||
.ToHashSet();
|
.ToHashSet();
|
||||||
|
|
||||||
|
// Payment terms dropdown — pre-select selectedTerms if provided, else company default
|
||||||
|
var prefs = await _unitOfWork.CompanyPreferences
|
||||||
|
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
||||||
|
var defaultTerms = selectedTerms ?? prefs?.DefaultPaymentTerms ?? "Net 30";
|
||||||
|
ViewBag.PaymentTermsOptions = BuildPaymentTermsSelectList(defaultTerms);
|
||||||
|
|
||||||
// Merchandise items for the invoice merch picker (all active IsMerchandise items)
|
// Merchandise items for the invoice merch picker (all active IsMerchandise items)
|
||||||
var allMerchItems = await _unitOfWork.CatalogItems.FindAsync(
|
var allMerchItems = await _unitOfWork.CatalogItems.FindAsync(
|
||||||
i => i.IsMerchandise && i.IsActive, false, i => i.Category);
|
i => i.IsMerchandise && i.IsActive, false, i => i.Category);
|
||||||
@@ -1787,7 +1904,9 @@ public class InvoicesController : Controller
|
|||||||
private async Task PopulateBankAccountsAsync()
|
private async Task PopulateBankAccountsAsync()
|
||||||
{
|
{
|
||||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive
|
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive
|
||||||
&& (a.AccountSubType == AccountSubType.Checking || a.AccountSubType == AccountSubType.Savings));
|
&& (a.AccountSubType == AccountSubType.Cash ||
|
||||||
|
a.AccountSubType == AccountSubType.Checking ||
|
||||||
|
a.AccountSubType == AccountSubType.Savings));
|
||||||
ViewBag.BankAccounts = accounts
|
ViewBag.BankAccounts = accounts
|
||||||
.OrderBy(a => a.AccountNumber)
|
.OrderBy(a => a.AccountNumber)
|
||||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||||||
|
|||||||
@@ -175,11 +175,11 @@
|
|||||||
<a tabindex="0" class="help-icon" role="button"
|
<a tabindex="0" class="help-icon" role="button"
|
||||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||||
data-bs-title="Payment Terms"
|
data-bs-title="Payment Terms"
|
||||||
data-bs-content="Free-text field that prints on the invoice (e.g., 'Net 30', 'Due on Receipt', '2% 10 Net 30'). Pre-filled from the customer's default payment terms. Changing it here only affects this invoice.">
|
data-bs-content="Prints on the invoice. Pre-filled from your App Defaults. Changing it here only affects this invoice.">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<input asp-for="Terms" class="form-control" placeholder="e.g. Net 30" />
|
<select asp-for="Terms" asp-items="ViewBag.PaymentTermsOptions" class="form-select"></select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -446,6 +446,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
|
<script src="~/js/invoice-due-date.js"></script>
|
||||||
<script>
|
<script>
|
||||||
let itemCount = @Model.InvoiceItems.Count;
|
let itemCount = @Model.InvoiceItems.Count;
|
||||||
const merchandiseItems = @Html.Raw(ViewBag.MerchandiseItems ?? "[]");
|
const merchandiseItems = @Html.Raw(ViewBag.MerchandiseItems ?? "[]");
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
var statusDisplay = InvoicesController.GetStatusDisplay(Model.Status);
|
var statusDisplay = InvoicesController.GetStatusDisplay(Model.Status);
|
||||||
var isDraft = Model.Status == InvoiceStatus.Draft;
|
var isDraft = Model.Status == InvoiceStatus.Draft;
|
||||||
var isVoided = Model.Status == InvoiceStatus.Voided || Model.Status == InvoiceStatus.WrittenOff;
|
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 canPay = !isVoided && Model.BalanceDue > 0;
|
||||||
var canResend = !isDraft && !isVoided && Model.Status != InvoiceStatus.Paid;
|
var canResend = !isDraft && !isVoided && Model.Status != InvoiceStatus.Paid;
|
||||||
var hasEmail = !string.IsNullOrWhiteSpace(Model.CustomerEmail);
|
var hasEmail = !string.IsNullOrWhiteSpace(Model.CustomerEmail);
|
||||||
@@ -30,12 +31,16 @@
|
|||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="d-flex justify-content-end gap-2 mb-4">
|
<div class="d-flex justify-content-end gap-2 mb-4">
|
||||||
@if (isDraft)
|
@if (canEdit)
|
||||||
{
|
{
|
||||||
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-warning">
|
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-warning">
|
||||||
<i class="bi bi-pencil me-2"></i>Edit
|
<i class="bi bi-pencil me-2"></i>Edit
|
||||||
</a>
|
</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">
|
<a asp-action="DownloadPdf" asp-route-id="@Model.Id" class="btn btn-outline-secondary">
|
||||||
<i class="bi bi-file-pdf me-2"></i>PDF
|
<i class="bi bi-file-pdf me-2"></i>PDF
|
||||||
</a>
|
</a>
|
||||||
@@ -64,7 +69,7 @@
|
|||||||
<div class="alert alert-warning d-flex align-items-center gap-2 mb-4">
|
<div class="alert alert-warning d-flex align-items-center gap-2 mb-4">
|
||||||
<i class="bi bi-envelope-slash fs-5"></i>
|
<i class="bi bi-envelope-slash fs-5"></i>
|
||||||
<span>
|
<span>
|
||||||
<strong>@Model.CustomerName</strong> has no email address on file — email buttons are hidden.
|
<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>.
|
<a asp-controller="Customers" asp-action="Edit" asp-route-id="@Model.CustomerId" class="alert-link">Add one in customer settings</a>.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -566,31 +571,37 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-2">
|
||||||
@if (isDraft)
|
@if (canEdit)
|
||||||
{
|
{
|
||||||
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-outline-primary">
|
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-outline-primary">
|
||||||
<i class="bi bi-pencil me-2"></i>Edit Invoice
|
<i class="bi bi-pencil me-2"></i>Edit Invoice
|
||||||
</a>
|
</a>
|
||||||
@if (hasEmail)
|
<form id="sendInvoiceForm" asp-action="Send" asp-route-id="@Model.Id" method="post">
|
||||||
{
|
@Html.AntiForgeryToken()
|
||||||
<form id="sendInvoiceForm" asp-action="Send" asp-route-id="@Model.Id" method="post">
|
<input type="hidden" name="overrideEmail" id="sendInvoiceOverrideEmail" value="" />
|
||||||
@Html.AntiForgeryToken()
|
@if (emailOptedOut)
|
||||||
@if (emailOptedOut)
|
{
|
||||||
{
|
<button type="button" class="btn btn-primary w-100" disabled
|
||||||
<button type="button" class="btn btn-primary w-100" disabled
|
title="Email notifications are turned off for this customer">
|
||||||
title="Email notifications are turned off for this customer">
|
<i class="bi bi-send me-2"></i>Send Invoice
|
||||||
<i class="bi bi-send me-2"></i>Send Invoice
|
</button>
|
||||||
</button>
|
}
|
||||||
}
|
else if (hasEmail)
|
||||||
else
|
{
|
||||||
{
|
<button type="button" class="btn btn-primary w-100"
|
||||||
<button type="button" class="btn btn-primary w-100"
|
data-bs-toggle="modal" data-bs-target="#sendInvoiceModal">
|
||||||
data-bs-toggle="modal" data-bs-target="#sendInvoiceModal">
|
<i class="bi bi-send me-2"></i>Send Invoice
|
||||||
<i class="bi bi-send me-2"></i>Send Invoice
|
</button>
|
||||||
</button>
|
}
|
||||||
}
|
else
|
||||||
</form>
|
{
|
||||||
}
|
<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)
|
@if (canPay)
|
||||||
{
|
{
|
||||||
@@ -598,12 +609,23 @@
|
|||||||
<i class="bi bi-cash me-2"></i>Record Payment
|
<i class="bi bi-cash me-2"></i>Record Payment
|
||||||
</button>
|
</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">
|
<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
|
<i class="bi bi-file-pdf me-2"></i>Download PDF
|
||||||
</a>
|
</a>
|
||||||
@if (canResend && hasEmail)
|
@if (canResend)
|
||||||
{
|
{
|
||||||
@if (emailOptedOut)
|
@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
|
<button type="button" class="btn btn-outline-primary" disabled
|
||||||
title="Email notifications are turned off for this customer">
|
title="Email notifications are turned off for this customer">
|
||||||
@@ -978,6 +1000,34 @@
|
|||||||
</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) -->
|
<!-- Re-send Invoice Modal (AJAX) -->
|
||||||
<div class="modal fade" id="resendInvoiceModal" tabindex="-1" aria-hidden="true">
|
<div class="modal fade" id="resendInvoiceModal" tabindex="-1" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
@@ -1299,7 +1349,27 @@
|
|||||||
}, 400);
|
}, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resendInvoice(invoiceId) {
|
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('resendInvoiceSending').classList.remove('d-none');
|
||||||
document.getElementById('resendInvoiceResult').classList.add('d-none');
|
document.getElementById('resendInvoiceResult').classList.add('d-none');
|
||||||
document.getElementById('resendInvoiceFooter').classList.add('d-none');
|
document.getElementById('resendInvoiceFooter').classList.add('d-none');
|
||||||
@@ -1309,8 +1379,10 @@
|
|||||||
modal.show();
|
modal.show();
|
||||||
|
|
||||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||||
|
let url = '@Url.Action("ResendInvoice", "Invoices")?id=' + invoiceId;
|
||||||
|
if (overrideEmail) url += '&overrideEmail=' + encodeURIComponent(overrideEmail);
|
||||||
|
|
||||||
fetch('@Url.Action("ResendInvoice", "Invoices")?id=' + invoiceId, {
|
fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'RequestVerificationToken': token, 'X-Requested-With': 'XMLHttpRequest' }
|
headers: { 'RequestVerificationToken': token, 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
var invoiceId = (int)(ViewBag.InvoiceId ?? 0);
|
var invoiceId = (int)(ViewBag.InvoiceId ?? 0);
|
||||||
var jobNumber = ViewBag.JobNumber as string;
|
var jobNumber = ViewBag.JobNumber as string;
|
||||||
var customerName = ViewBag.CustomerName as string;
|
var customerName = ViewBag.CustomerName as string;
|
||||||
|
var canResend = ViewBag.CanResend == true;
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
@@ -37,7 +38,7 @@
|
|||||||
<a tabindex="0" class="help-icon" role="button"
|
<a tabindex="0" class="help-icon" role="button"
|
||||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||||
data-bs-title="Invoice Details"
|
data-bs-title="Invoice Details"
|
||||||
data-bs-content="Invoice Date is the date of issue and the reference for payment terms. Due Date drives overdue status and A/R aging. Payment Terms prints on the invoice — changing it here only affects this invoice. Only Draft invoices can be edited; sending locks the invoice.">
|
data-bs-content="Invoice Date is the date of issue and the reference for payment terms. Due Date drives overdue status and A/R aging. Payment Terms prints on the invoice — changing it here only affects this invoice. Draft, Sent, and Overdue invoices can be edited; Paid and Partially Paid invoices are locked.">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,7 +65,7 @@
|
|||||||
<div class="row g-3 mt-1">
|
<div class="row g-3 mt-1">
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
<label asp-for="Terms" class="form-label fw-semibold">Payment Terms</label>
|
<label asp-for="Terms" class="form-label fw-semibold">Payment Terms</label>
|
||||||
<input asp-for="Terms" class="form-control" placeholder="e.g. Net 30" />
|
<select asp-for="Terms" asp-items="ViewBag.PaymentTermsOptions" class="form-select"></select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -234,6 +235,15 @@
|
|||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="card border-0 shadow-sm">
|
<div class="card border-0 shadow-sm">
|
||||||
<div class="card-body d-grid gap-2">
|
<div class="card-body d-grid gap-2">
|
||||||
|
@if (canResend)
|
||||||
|
{
|
||||||
|
<div class="form-check mb-1">
|
||||||
|
<input class="form-check-input" type="checkbox" name="resendToCustomer" value="true" id="resendCheck" />
|
||||||
|
<label class="form-check-label small" for="resendCheck">
|
||||||
|
<i class="bi bi-send me-1"></i>Re-send updated invoice to customer
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
<i class="bi bi-check-circle me-2"></i>Save Changes
|
<i class="bi bi-check-circle me-2"></i>Save Changes
|
||||||
</button>
|
</button>
|
||||||
@@ -242,9 +252,10 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer border-0 pt-0">
|
<div class="card-footer border-0 pt-0">
|
||||||
<div class="alert alert-warning mb-0 small py-2">
|
<div class="alert alert-info mb-0 small py-2">
|
||||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
Only <strong>Draft</strong> invoices can be edited. Send the invoice to lock it.
|
<strong>Draft, Sent,</strong> and <strong>Overdue</strong> invoices can be edited.
|
||||||
|
Paid invoices are locked.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Auto-calculates invoice Due Date from Invoice Date + Payment Terms.
|
||||||
|
* Parses common terms formats: "Net 30", "N/15", "Due on Receipt", "COD", "2% 10 Net 30".
|
||||||
|
* Only fires when Terms or Invoice Date changes; user can always override the Due Date field.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the net payment days from a free-text terms string.
|
||||||
|
/// Returns null when the string can't be parsed (due date is left unchanged).
|
||||||
|
/// </summary>
|
||||||
|
function parseDays(terms) {
|
||||||
|
if (!terms || !terms.trim()) return null;
|
||||||
|
const t = terms.trim().toLowerCase();
|
||||||
|
if (/\b(receipt|due\s*now|cod|immediate)\b/.test(t)) return 0;
|
||||||
|
// "Net N" or "N/N" (e.g., "2% 10 Net 30" or "N/30")
|
||||||
|
let m = t.match(/\bnet\s+(\d+)/) || t.match(/\bn\/(\d+)/);
|
||||||
|
if (m) return parseInt(m[1], 10);
|
||||||
|
// Plain number (e.g., "30 days", "30")
|
||||||
|
m = t.match(/\b(\d+)\b/);
|
||||||
|
if (m) return parseInt(m[1], 10);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recalcDueDate() {
|
||||||
|
const termsEl = document.getElementById('Terms');
|
||||||
|
const invoiceDateEl = document.getElementById('InvoiceDate');
|
||||||
|
const dueDateEl = document.getElementById('DueDate');
|
||||||
|
if (!termsEl || !invoiceDateEl || !dueDateEl) return;
|
||||||
|
|
||||||
|
const days = parseDays(termsEl.value);
|
||||||
|
if (days === null) return;
|
||||||
|
|
||||||
|
const rawDate = invoiceDateEl.value;
|
||||||
|
if (!rawDate) return;
|
||||||
|
|
||||||
|
// Parse as local date to avoid UTC-offset shifting the day
|
||||||
|
const [y, mo, d] = rawDate.split('-').map(Number);
|
||||||
|
const due = new Date(y, mo - 1, d + days);
|
||||||
|
const yyyy = due.getFullYear();
|
||||||
|
const mm = String(due.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(due.getDate()).padStart(2, '0');
|
||||||
|
dueDateEl.value = `${yyyy}-${mm}-${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const termsEl = document.getElementById('Terms');
|
||||||
|
const invoiceDateEl = document.getElementById('InvoiceDate');
|
||||||
|
if (termsEl) {
|
||||||
|
termsEl.addEventListener('change', recalcDueDate);
|
||||||
|
termsEl.addEventListener('blur', recalcDueDate);
|
||||||
|
}
|
||||||
|
if (invoiceDateEl) {
|
||||||
|
invoiceDateEl.addEventListener('change', recalcDueDate);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user