Files
PowderCoatingLogix/src/PowderCoating.Web/Views/Invoices/Create.cshtml
T
spouliot 3278152d83 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>
2026-05-08 20:47:34 -04:00

764 lines
43 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@using PowderCoating.Core.Entities
@model PowderCoating.Application.DTOs.Invoice.CreateInvoiceDto
@{
ViewData["Title"] = "Create Invoice";
ViewData["PageIcon"] = "bi-receipt";
ViewData["PageHelpTitle"] = "Create Invoice";
ViewData["PageHelpContent"] = "Invoices start as Drafts — you can freely edit them until you click Send. Once sent, the invoice is locked and the customer is emailed. Line items are pre-populated from the job&apos;s items but you can add, edit, or remove any line before sending. Partial payments are supported after sending.";
var jobNumber = ViewBag.JobNumber as string;
var customerName = ViewBag.CustomerName as string;
var customers = ViewBag.Customers as List<Customer>;
bool hasJob = !string.IsNullOrEmpty(jobNumber);
}
@section Styles {
<style>
.merch-combo-dropdown { background: #fff; border: 1px solid #dee2e6; color: #212529; }
[data-bs-theme="dark"] .merch-combo-dropdown { background: var(--bs-body-bg) !important; border-color: var(--bs-border-color) !important; color: var(--bs-body-color) !important; }
[data-bs-theme="dark"] .merch-opt { color: var(--bs-body-color) !important; }
[data-bs-theme="dark"] .gc-input { background: #3a2e00 !important; color: var(--bs-body-color) !important; }
</style>
}
<div class="d-flex justify-content-between align-items-center mb-4">
@if (hasJob)
{
<p class="text-muted mb-0 small">Job #@jobNumber &mdash; @customerName</p>
}
else
{
<div></div>
}
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to Invoices
</a>
</div>
<form asp-action="Create" method="post" id="invoiceForm">
@Html.AntiForgeryToken()
<input type="hidden" name="guidedActivation" value="@ViewBag.GuidedActivation" />
@if (!ViewData.ModelState.IsValid)
{
<div class="alert alert-danger alert-dismissible fade show mb-3" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i>
<div asp-validation-summary="All" class="d-inline"></div>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@if (ViewBag.GuidedActivation != null)
{
<div class="alert alert-primary alert-permanent border-0 shadow-sm mb-4">
<div class="fw-semibold mb-1">Optional next step: Create the invoice</div>
<div>This uses the real invoice flow. Review the line items, then save when you want to close the loop with billing.</div>
</div>
}
<input type="hidden" asp-for="PreparedById" />
<input type="hidden" asp-for="JobId" />
<input type="hidden" asp-for="CustomerId" id="hiddenCustomerId" />
<div class="row g-4">
<!-- LEFT: Main form -->
<div class="col-lg-8">
<!-- Customer / Job Info -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header border-0 py-3">
<h6 class="mb-0"><i class="bi bi-person-circle me-2"></i>Customer &amp; Job</h6>
</div>
<div class="card-body">
@if (hasJob)
{
<div class="row g-3">
<div class="col-md-6">
<label class="form-label text-muted small">Customer</label>
<p class="fw-semibold mb-0">@customerName</p>
</div>
<div class="col-md-6">
<label class="form-label text-muted small">Job #</label>
<p class="fw-semibold mb-0">
@if (Model.JobId.HasValue)
{
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@Model.JobId"
class="text-decoration-none">@jobNumber</a>
}
else
{
<span class="text-muted">Merchandise Sale</span>
}
</p>
</div>
</div>
}
else
{
<div class="row g-3">
<div class="col-md-6">
<label class="form-label fw-semibold">Customer <span class="text-danger">*</span></label>
<select class="form-select @(ViewData.ModelState["CustomerId"]?.Errors.Count > 0 ? "is-invalid" : "")"
id="customerSelect" onchange="onCustomerChanged(this)">
<option value="">-- Select Customer --</option>
@if (customers != null)
{
foreach (var c in customers)
{
var name = c.IsCommercial
? c.CompanyName
: $"{c.ContactFirstName} {c.ContactLastName}".Trim();
if (c.IsTaxExempt) name += " ★";
<option value="@c.Id" selected="@(Model.CustomerId == c.Id)" data-tax-exempt="@c.IsTaxExempt.ToString().ToLower()">@name</option>
}
}
</select>
<span asp-validation-for="CustomerId" class="invalid-feedback"></span>
</div>
</div>
}
</div>
</div>
<!-- Invoice Details -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header border-0 py-3">
<div class="d-flex align-items-center gap-2">
<h6 class="mb-0"><i class="bi bi-info-circle me-2"></i>Invoice Details</h6>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Invoice Details"
data-bs-content="Invoice Date is the date of issue — this is what appears on the printed invoice and determines when payment terms start counting. Due Date drives overdue status and A/R aging reports. Payment Terms is free text (e.g., 'Net 30') that prints on the invoice; it defaults from the customer's settings but you can override it here.">
<i class="bi bi-question-circle"></i>
</a>
</div>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<div class="d-flex align-items-center gap-1">
<label asp-for="InvoiceDate" class="form-label fw-semibold mb-0">Invoice Date <span class="text-danger">*</span></label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Invoice Date"
data-bs-content="The date the invoice is issued. This appears on the printed document and is the reference date for payment terms — e.g., Net 30 means payment is due 30 days after this date. Defaults to today.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<input asp-for="InvoiceDate" type="date" class="form-control"
value="@Model.InvoiceDate.ToString("yyyy-MM-dd")" />
<span asp-validation-for="InvoiceDate" class="text-danger small"></span>
</div>
<div class="col-md-4">
<div class="d-flex align-items-center gap-1">
<label asp-for="DueDate" class="form-label fw-semibold mb-0">Due Date</label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Due Date"
data-bs-content="When payment is expected. Once this date passes with an unpaid balance, the invoice status changes to Overdue and it appears in red on the list. Defaults to Invoice Date + the customer's payment terms days.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<input asp-for="DueDate" type="date" class="form-control"
value="@(Model.DueDate.HasValue ? Model.DueDate.Value.ToString("yyyy-MM-dd") : "")" />
<span asp-validation-for="DueDate" class="text-danger small"></span>
</div>
<div class="col-md-4">
<label asp-for="CustomerPO" class="form-label fw-semibold mb-0">Customer PO #</label>
<input asp-for="CustomerPO" class="form-control" placeholder="Optional" />
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-12">
<div class="d-flex align-items-center gap-1">
<label asp-for="Terms" class="form-label fw-semibold mb-0">Payment Terms</label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Payment Terms"
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>
</a>
</div>
<select asp-for="Terms" asp-items="ViewBag.PaymentTermsOptions" class="form-select"></select>
</div>
</div>
</div>
</div>
<!-- Line Items -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header border-0 py-3 d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-2">
<h6 class="mb-0"><i class="bi bi-list-ul me-2"></i>Line Items</h6>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Line Items"
data-bs-content="Each row is a billable line on the invoice. Pre-populated from the job's items. Qty × Unit Price = Total per line; you can override the Total directly too. Color is optional — it appears under the description on the printed invoice. Add manual lines for anything not in the job (e.g., pickup fee, rush charge).">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-success" onclick="toggleMerchPicker()"
id="addMerchBtn" title="Add a merchandise item from your catalog">
<i class="bi bi-shop me-1"></i>Add Merchandise
</button>
<button type="button" class="btn btn-sm btn-outline-warning" data-bs-toggle="modal" data-bs-target="#gcModal"
title="Sell a gift certificate">
<i class="bi bi-gift me-1"></i>Gift Certificate
</button>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="addLineItem()">
<i class="bi bi-plus-circle me-1"></i>Add Line
</button>
</div>
</div>
<!-- Merchandise Picker (collapsed by default) -->
<div id="merchPickerPanel" class="border-bottom px-3 py-2 bg-light d-none">
<div class="d-flex gap-2 align-items-center">
<div class="position-relative flex-grow-1" id="merchComboWrapper">
<div class="input-group input-group-sm">
<input type="text" id="merchInput" class="form-control form-control-sm"
placeholder="Search merchandise..." autocomplete="off"
oninput="merchComboInput()"
onfocus="merchComboOpen()"
onkeydown="merchComboKey(event)" />
<button class="btn btn-outline-secondary btn-sm" type="button" tabindex="-1"
onclick="merchComboToggle()">
<i class="bi bi-chevron-down" style="font-size:.75rem;"></i>
</button>
</div>
<div id="merchDropdown"
class="merch-combo-dropdown"
style="display:none;max-height:220px;overflow-y:auto;z-index:1060;border-radius:0.375rem;box-shadow:0 4px 12px rgba(0,0,0,.12);position:fixed;">
</div>
</div>
<button type="button" class="btn btn-success btn-sm text-nowrap"
onclick="addMerchandiseLineItem()">
<i class="bi bi-plus-circle me-1"></i>Add
</button>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-borderless align-middle mb-0" id="lineItemsTable">
<thead class="table-light">
<tr>
<th style="min-width:220px;">Description</th>
<th style="width:70px;" class="text-center">Qty</th>
<th style="width:120px;" class="text-end">Unit Price</th>
<th style="width:120px;" class="text-end">Total</th>
<th style="width:80px;" class="text-center">Color</th>
<th style="width:40px;"></th>
</tr>
</thead>
<tbody id="lineItemsBody">
@for (int i = 0; i < Model.InvoiceItems.Count; i++)
{
var item = Model.InvoiceItems[i];
<tr class="line-item-row" data-index="@i">
<td>
<input type="hidden" name="InvoiceItems[@i].SourceJobItemId" value="@item.SourceJobItemId" />
<input type="hidden" name="InvoiceItems[@i].CatalogItemId" value="@item.CatalogItemId" />
<input type="hidden" name="InvoiceItems[@i].DisplayOrder" class="display-order-input" value="@(i + 1)" />
<input type="hidden" name="InvoiceItems[@i].RevenueAccountId" value="@item.RevenueAccountId" />
<input type="hidden" name="InvoiceItems[@i].IsGiftCertificate" value="@item.IsGiftCertificate.ToString().ToLower()" />
<input type="hidden" name="InvoiceItems[@i].GcRecipientName" value="@item.GcRecipientName" />
<input type="hidden" name="InvoiceItems[@i].GcRecipientEmail" value="@item.GcRecipientEmail" />
<input type="hidden" name="InvoiceItems[@i].GcExpiryDate" value="@(item.GcExpiryDate?.ToString("yyyy-MM-dd") ?? "")" />
<input type="text" name="InvoiceItems[@i].Description"
class="form-control form-control-sm"
value="@item.Description" placeholder="Description" required />
<input type="text" name="InvoiceItems[@i].Notes"
class="form-control form-control-sm mt-1"
value="@item.Notes" placeholder="Notes (optional)" />
</td>
<td class="text-center">
<input type="number" name="InvoiceItems[@i].Quantity"
class="form-control form-control-sm text-center qty-input"
value="@item.Quantity" min="0.01" step="0.01"
onchange="recalcRow(this)" oninput="recalcRow(this)" />
</td>
<td class="text-end">
<input type="number" name="InvoiceItems[@i].UnitPrice"
class="form-control form-control-sm text-end unit-price-input"
value="@item.UnitPrice.ToString("F2")" min="0" step="0.01"
onchange="recalcRow(this)" oninput="recalcRow(this)" />
</td>
<td class="text-end">
<input type="number" name="InvoiceItems[@i].TotalPrice"
class="form-control form-control-sm text-end total-price-input"
value="@item.TotalPrice.ToString("F2")" min="0" step="0.01"
oninput="recalcTotals()" />
</td>
<td class="text-center">
<input type="text" name="InvoiceItems[@i].ColorName"
class="form-control form-control-sm"
value="@item.ColorName" placeholder="Color" />
</td>
<td class="text-center">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeLineItem(this)" title="Remove">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<div id="noItemsMessage" class="text-center py-3 text-muted @(Model.InvoiceItems.Any() ? "d-none" : "")">
<i class="bi bi-receipt me-2"></i>No line items yet. Click "Add Line" to get started.
</div>
</div>
</div>
<!-- Notes -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header border-0 py-3">
<div class="d-flex align-items-center gap-2">
<h6 class="mb-0"><i class="bi bi-chat-text me-2"></i>Notes</h6>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Notes"
data-bs-content="Customer Notes appear on the printed and emailed invoice — use these for payment instructions, thank-you messages, or job-specific reminders. Internal Notes are only visible to staff here in the app and never sent to the customer.">
<i class="bi bi-question-circle"></i>
</a>
</div>
</div>
<div class="card-body">
<div class="mb-3">
<label asp-for="Notes" class="form-label fw-semibold">Customer Notes</label>
<textarea asp-for="Notes" class="form-control" rows="3"
placeholder="Notes visible to the customer on the invoice..."></textarea>
<div class="form-text">These notes appear on the printed/emailed invoice.</div>
</div>
<div class="mb-0">
<label asp-for="InternalNotes" class="form-label fw-semibold">Internal Notes</label>
<textarea asp-for="InternalNotes" class="form-control" rows="2"
placeholder="Internal notes (not visible to customer)..."></textarea>
</div>
</div>
</div>
</div>
<!-- RIGHT: Totals & Actions -->
<div class="col-lg-4">
<!-- Totals -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header border-0 py-3">
<div class="d-flex align-items-center gap-2">
<h6 class="mb-0"><i class="bi bi-calculator me-2"></i>Totals</h6>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Totals"
data-bs-content="Subtotal = sum of all line item totals. Discount is a flat dollar amount deducted before tax — use it for customer-specific deals or courtesy adjustments. Tax % is applied to (Subtotal Discount). Both default from the company settings but can be overridden per invoice.">
<i class="bi bi-question-circle"></i>
</a>
</div>
</div>
<div class="card-body">
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Subtotal</span>
<span id="displaySubtotal" class="fw-semibold">$0.00</span>
</div>
<div class="mb-2">
<div class="d-flex justify-content-between align-items-center mb-1">
<label class="form-label mb-0 text-muted">Discount ($)</label>
</div>
<input asp-for="DiscountAmount" type="number" class="form-control form-control-sm text-end"
min="0" step="0.01" oninput="recalcTotals()" />
</div>
<div class="mb-2">
<div class="d-flex justify-content-between align-items-center mb-1">
<label class="form-label mb-0 text-muted">Tax (%)</label>
</div>
<input asp-for="TaxPercent" type="number" class="form-control form-control-sm text-end"
min="0" max="100" step="0.01" oninput="recalcTotals()" />
</div>
<div class="d-flex justify-content-between mb-1">
<span class="text-muted small">Tax Amount</span>
<span id="displayTax" class="small">$0.00</span>
</div>
<hr />
<div class="d-flex justify-content-between">
<span class="fw-bold fs-5">Total</span>
<span id="displayTotal" class="fw-bold fs-5 text-primary">$0.00</span>
</div>
</div>
</div>
<!-- Actions -->
<div class="card border-0 shadow-sm">
<div class="card-body d-grid gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle me-2"></i>Create Invoice
</button>
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-x-circle me-1"></i>Cancel
</a>
</div>
</div>
</div>
</div>
</form>
<!-- Gift Certificate Modal -->
<div class="modal fade" id="gcModal" 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-warning"></i>Sell a Gift Certificate</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label fw-semibold">Face Value <span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" id="gcAmount" class="form-control" min="1" step="0.01" placeholder="0.00" />
</div>
<div class="form-text">The amount the customer can redeem later.</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Recipient Name <span class="text-muted fw-normal">(optional)</span></label>
<input type="text" id="gcRecipientName" class="form-control" placeholder="Name on the certificate" />
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Recipient Email <span class="text-muted fw-normal">(optional)</span></label>
<input type="email" id="gcRecipientEmail" class="form-control" placeholder="For emailing the certificate" />
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Expiry Date <span class="text-muted fw-normal">(optional)</span></label>
<input type="date" id="gcExpiryDate" class="form-control" />
<div class="form-text">Leave blank for no expiry.</div>
</div>
<div id="gcModalError" class="alert alert-danger d-none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-warning" onclick="addGiftCertLineItem(this)">
<i class="bi bi-plus-circle me-1"></i>Add to Invoice
</button>
</div>
</div>
</div>
</div>
@section Scripts {
<script src="~/js/invoice-due-date.js"></script>
<script>
let itemCount = @Model.InvoiceItems.Count;
const merchandiseItems = @Html.Raw(ViewBag.MerchandiseItems ?? "[]");
const companyTaxPercent = @((ViewBag.CompanyTaxPercent ?? 0).ToString());
const taxExemptCustomerIds = new Set(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.TaxExemptCustomerIds ?? new System.Collections.Generic.HashSet<int>())));
function onCustomerChanged(select) {
document.getElementById('hiddenCustomerId').value = select.value;
const customerId = parseInt(select.value) || 0;
const taxField = document.getElementById('TaxPercent');
if (taxField) {
taxField.value = taxExemptCustomerIds.has(customerId) ? 0 : companyTaxPercent;
recalcTotals();
}
}
// ── Merchandise combobox ────────────────────────────────────────────────
let _selectedMerchItem = null;
function toggleMerchPicker() {
const panel = document.getElementById('merchPickerPanel');
panel.classList.toggle('d-none');
if (!panel.classList.contains('d-none')) {
_selectedMerchItem = null;
document.getElementById('merchInput').value = '';
document.getElementById('merchInput').focus();
merchComboOpen();
} else {
merchComboClose();
}
}
function merchComboInput() {
_selectedMerchItem = null;
const q = document.getElementById('merchInput').value.toLowerCase();
merchComboRender(q);
merchComboShow();
}
function merchComboOpen() {
const q = document.getElementById('merchInput').value.toLowerCase();
merchComboRender(q);
merchComboShow();
}
function merchComboToggle() {
const dd = document.getElementById('merchDropdown');
if (dd.style.display === 'none') {
document.getElementById('merchInput').focus();
merchComboOpen();
} else {
merchComboClose();
}
}
function merchComboRender(query) {
const dd = document.getElementById('merchDropdown');
const filtered = query
? merchandiseItems.filter(i =>
i.name.toLowerCase().includes(query) ||
(i.sKU && i.sKU.toLowerCase().includes(query)) ||
i.categoryName.toLowerCase().includes(query))
: merchandiseItems;
if (filtered.length === 0) {
dd.innerHTML = '<div class="px-3 py-2 text-muted small">No items match your search</div>';
return;
}
// Group by category
const groups = {};
filtered.forEach(i => {
if (!groups[i.categoryName]) groups[i.categoryName] = [];
groups[i.categoryName].push(i);
});
dd.innerHTML = Object.keys(groups).sort().map(cat =>
`<div class="px-3 pt-2 pb-1" style="font-size:.75rem;font-weight:600;color:#6c757d;text-transform:uppercase;letter-spacing:.05em;">${cat}</div>` +
groups[cat].map(i =>
`<div class="merch-opt" style="padding:.35rem .75rem .35rem 1.25rem;font-size:.85rem;cursor:pointer;"
data-id="${i.id}" data-name="${i.name.replace(/"/g,'&quot;')}"
data-price="${i.defaultPrice}" data-revenue="${i.revenueAccountId ?? ''}"
onmousedown="event.preventDefault();merchComboSelect(this)"
onmouseenter="this.style.background=document.documentElement.getAttribute('data-bs-theme')==='dark'?'#2c3a5a':'#f0f4ff'"
onmouseleave="this.classList.contains('mc-active')?null:this.style.background=''">
${i.name}${i.sKU ? ' <span class="text-muted">[' + i.sKU + ']</span>' : ''} <span class="text-muted">— ${formatCurrency(i.defaultPrice)}</span>
</div>`
).join('')
).join('');
}
function merchComboShow() {
const dd = document.getElementById('merchDropdown');
const anchor = document.getElementById('merchInput');
const rect = anchor.closest('.input-group').getBoundingClientRect();
dd.style.top = (rect.bottom + 2) + 'px';
dd.style.left = rect.left + 'px';
dd.style.width = rect.width + 'px';
dd.style.display = 'block';
}
function merchComboClose() {
document.getElementById('merchDropdown').style.display = 'none';
}
function merchComboSelect(el) {
_selectedMerchItem = {
id: el.dataset.id,
name: el.dataset.name,
defaultPrice: parseFloat(el.dataset.price),
revenueAccountId: el.dataset.revenue
};
document.getElementById('merchInput').value = el.dataset.name;
merchComboClose();
}
function merchComboKey(event) {
const dd = document.getElementById('merchDropdown');
if (dd.style.display === 'none') {
if (event.key === 'ArrowDown' || event.key === 'Enter') { event.preventDefault(); merchComboOpen(); }
return;
}
const items = Array.from(dd.querySelectorAll('.merch-opt'));
let idx = items.findIndex(it => it.classList.contains('mc-active'));
if (event.key === 'ArrowDown') {
event.preventDefault();
idx = Math.min(idx + 1, items.length - 1);
items.forEach(it => { it.classList.remove('mc-active'); it.style.background = ''; });
const activeColor = document.documentElement.getAttribute('data-bs-theme') === 'dark' ? '#2c3a5a' : '#e8eeff';
if (items[idx]) { items[idx].classList.add('mc-active'); items[idx].style.background = activeColor; items[idx].scrollIntoView({ block: 'nearest' }); }
} else if (event.key === 'ArrowUp') {
event.preventDefault();
idx = Math.max(idx - 1, 0);
items.forEach(it => { it.classList.remove('mc-active'); it.style.background = ''; });
const activeColorUp = document.documentElement.getAttribute('data-bs-theme') === 'dark' ? '#2c3a5a' : '#e8eeff';
if (items[idx]) { items[idx].classList.add('mc-active'); items[idx].style.background = activeColorUp; items[idx].scrollIntoView({ block: 'nearest' }); }
} else if (event.key === 'Enter') {
event.preventDefault();
const active = dd.querySelector('.mc-active') || items[0];
if (active) active.dispatchEvent(new MouseEvent('mousedown'));
else addMerchandiseLineItem();
} else if (event.key === 'Escape') {
merchComboClose();
}
}
function addMerchandiseLineItem() {
const item = _selectedMerchItem;
if (!item) return;
addLineItem(item.name, 1, item.defaultPrice, '', item.revenueAccountId, item.id.toString());
_selectedMerchItem = null;
document.getElementById('merchInput').value = '';
document.getElementById('merchPickerPanel').classList.add('d-none');
merchComboClose();
}
function addGiftCertLineItem(btn) {
// Bootstrap teleports modals to <body> — navigate relative to the button
const modalEl = btn ? btn.closest('.modal') : document.getElementById('gcModal');
const q = sel => modalEl ? modalEl.querySelector(sel) : document.querySelector(sel);
const errEl = q('#gcModalError');
if (errEl) errEl.classList.add('d-none');
const amountStr = q('#gcAmount')?.value ?? '';
const amount = parseFloat(amountStr);
if (!amountStr || isNaN(amount) || amount <= 0) {
if (errEl) { errEl.textContent = 'Please enter a valid amount greater than $0.'; errEl.classList.remove('d-none'); }
return;
}
const enteredName = (q('#gcRecipientName')?.value ?? '').trim();
const recipientEmail = (q('#gcRecipientEmail')?.value ?? '').trim();
const expiryDate = q('#gcExpiryDate')?.value ?? '';
// Fall back to selected customer name when recipient is blank
const customerSel = document.getElementById('customerSelect');
const customerName = customerSel ? customerSel.options[customerSel.selectedIndex]?.text ?? '' : '';
const recipientName = enteredName || (customerSel?.value ? customerName : '');
const description = 'Gift Certificate ($' + amount.toFixed(2) + ')' +
(recipientName ? ' for ' + recipientName : '');
addLineItem(description, 1, amount, '', '', '', true, recipientName, recipientEmail, expiryDate);
// Reset fields
if (q('#gcAmount')) q('#gcAmount').value = '';
if (q('#gcRecipientName')) q('#gcRecipientName').value = '';
if (q('#gcRecipientEmail'))q('#gcRecipientEmail').value = '';
if (q('#gcExpiryDate')) q('#gcExpiryDate').value = '';
const modal = bootstrap.Modal.getInstance(modalEl) || new bootstrap.Modal(modalEl);
modal.hide();
}
function addLineItem(description = '', qty = 1, unitPrice = 0, color = '', revenueAccountId = '', catalogItemId = '',
isGiftCertificate = false, gcRecipientName = '', gcRecipientEmail = '', gcExpiryDate = '') {
const idx = itemCount++;
const tbody = document.getElementById('lineItemsBody');
const row = document.createElement('tr');
row.className = 'line-item-row';
row.dataset.index = idx;
if (isGiftCertificate) row.dataset.isGc = 'true';
const total = (qty * unitPrice).toFixed(2);
row.innerHTML = `
<td>
<input type="hidden" name="InvoiceItems[${idx}].SourceJobItemId" value="" />
<input type="hidden" name="InvoiceItems[${idx}].CatalogItemId" value="${catalogItemId}" />
<input type="hidden" name="InvoiceItems[${idx}].DisplayOrder" class="display-order-input" value="${idx + 1}" />
<input type="hidden" name="InvoiceItems[${idx}].RevenueAccountId" value="${revenueAccountId}" />
<input type="hidden" name="InvoiceItems[${idx}].IsGiftCertificate" value="${isGiftCertificate}" />
<input type="hidden" name="InvoiceItems[${idx}].GcRecipientName" value="${gcRecipientName.replace(/"/g, '&quot;')}" />
<input type="hidden" name="InvoiceItems[${idx}].GcRecipientEmail" value="${gcRecipientEmail.replace(/"/g, '&quot;')}" />
<input type="hidden" name="InvoiceItems[${idx}].GcExpiryDate" value="${gcExpiryDate}" />
<input type="text" name="InvoiceItems[${idx}].Description"
class="form-control form-control-sm" placeholder="Description" value="${description.replace(/"/g, '&quot;')}" required
${isGiftCertificate ? 'readonly class="gc-input"' : ''} />
<input type="text" name="InvoiceItems[${idx}].Notes"
class="form-control form-control-sm mt-1" placeholder="Notes (optional)" />
${isGiftCertificate ? '<small class="text-warning fw-semibold"><i class="bi bi-gift me-1"></i> Gift Certificate - code generated on save</small>' : ''}
</td>
<td class="text-center">
<input type="number" name="InvoiceItems[${idx}].Quantity"
class="form-control form-control-sm text-center qty-input"
value="${qty}" min="0.01" step="0.01"
onchange="recalcRow(this)" oninput="recalcRow(this)" />
</td>
<td class="text-end">
<input type="number" name="InvoiceItems[${idx}].UnitPrice"
class="form-control form-control-sm text-end unit-price-input"
value="${unitPrice.toFixed(2)}" min="0" step="0.01"
onchange="recalcRow(this)" oninput="recalcRow(this)" />
</td>
<td class="text-end">
<input type="number" name="InvoiceItems[${idx}].TotalPrice"
class="form-control form-control-sm text-end total-price-input"
value="${total}" min="0" step="0.01"
oninput="recalcTotals()" />
</td>
<td class="text-center">
<input type="text" name="InvoiceItems[${idx}].ColorName"
class="form-control form-control-sm" placeholder="Color" value="${color}" />
</td>
<td class="text-center">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeLineItem(this)" title="Remove">
<i class="bi bi-trash"></i>
</button>
</td>`;
tbody.appendChild(row);
document.getElementById('noItemsMessage').classList.add('d-none');
reindexRows();
recalcTotals();
row.querySelector('input[name$="].Description"]').focus();
}
function removeLineItem(btn) {
const row = btn.closest('tr');
row.remove();
reindexRows();
recalcTotals();
const tbody = document.getElementById('lineItemsBody');
if (tbody.querySelectorAll('tr.line-item-row').length === 0)
document.getElementById('noItemsMessage').classList.remove('d-none');
}
function reindexRows() {
const rows = document.querySelectorAll('#lineItemsBody tr.line-item-row');
rows.forEach((row, i) => {
row.dataset.index = i;
row.querySelectorAll('input, select, textarea').forEach(input => {
if (input.name) {
input.name = input.name.replace(/InvoiceItems\[\d+\]/, `InvoiceItems[${i}]`);
}
});
const orderInput = row.querySelector('.display-order-input');
if (orderInput) orderInput.value = i + 1;
});
itemCount = rows.length;
}
function recalcRow(input) {
const row = input.closest('tr');
const qty = parseFloat(row.querySelector('.qty-input')?.value) || 0;
const unit = parseFloat(row.querySelector('.unit-price-input')?.value) || 0;
const totalInput = row.querySelector('.total-price-input');
if (totalInput) totalInput.value = (qty * unit).toFixed(2);
recalcTotals();
}
function recalcTotals() {
let subtotal = 0;
document.querySelectorAll('#lineItemsBody .total-price-input').forEach(input => {
subtotal += parseFloat(input.value) || 0;
});
const discount = parseFloat(document.getElementById('DiscountAmount')?.value) || 0;
const taxPct = parseFloat(document.getElementById('TaxPercent')?.value) || 0;
const taxableAmount = subtotal - discount;
const tax = Math.round(taxableAmount * taxPct) / 100;
const total = taxableAmount + tax;
document.getElementById('displaySubtotal').textContent = formatCurrency(subtotal);
document.getElementById('displayTax').textContent = formatCurrency(tax);
document.getElementById('displayTotal').textContent = formatCurrency(total);
}
function formatCurrency(value) {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(value);
}
// Initial calculation on load
recalcTotals();
</script>
}