Files
PowderCoatingLogix/src/PowderCoating.Web/Views/Invoices/Create.cshtml
T
spouliot 27aa4e0ea6 Invoice create: show discount row in totals, allow negative line items
- Add "Discount Applied" display row (red, hidden when zero) between subtotal
  and tax so users can see the discount being deducted at a glance
- Remove min="0" from UnitPrice and TotalPrice inputs (server-rendered and JS
  template) so negative adjustment lines can be entered without form rejection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 11:41:47 -04:00

825 lines
46 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" />
<input type="hidden" asp-for="EarlyPaymentDiscountPercent" id="EarlyPaymentDiscountPercent" />
<input type="hidden" asp-for="EarlyPaymentDiscountDays" id="EarlyPaymentDiscountDays" />
<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 id="earlyPaymentDiscountNotice" class="form-text text-success d-none"></div>
</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")" 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")" 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 id="discountRow" class="d-flex justify-content-between mb-1 d-none">
<span class="text-muted small">Discount Applied</span>
<span id="displayDiscount" class="small text-danger">&minus;$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">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-outline-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;
if (!customerId) return;
// Fetch payment terms + tax rate for the selected customer
fetch(`/Invoices/GetCustomerPaymentTerms?customerId=${customerId}`)
.then(r => r.ok ? r.json() : null)
.then(data => {
if (!data) return;
// Auto-fill Terms dropdown
const termsSelect = document.getElementById('Terms');
if (termsSelect && data.paymentTerms) {
// Set the matching option, or fall back to the raw value
const opt = Array.from(termsSelect.options).find(o => o.value === data.paymentTerms);
if (opt) termsSelect.value = data.paymentTerms;
// Trigger due date recalculation (invoice-due-date.js listens to 'change')
termsSelect.dispatchEvent(new Event('change'));
}
// Show/hide early payment discount notice and persist values
const discountEl = document.getElementById('earlyPaymentDiscountNotice');
const discountPctField = document.getElementById('EarlyPaymentDiscountPercent');
const discountDaysField = document.getElementById('EarlyPaymentDiscountDays');
if (discountEl) {
if (data.earlyPaymentDiscountPercent > 0) {
discountEl.textContent = `${data.earlyPaymentDiscountPercent}% discount if paid within ${data.earlyPaymentDiscountDays} days`;
discountEl.classList.remove('d-none');
} else {
discountEl.classList.add('d-none');
}
}
if (discountPctField) discountPctField.value = data.earlyPaymentDiscountPercent ?? 0;
if (discountDaysField) discountDaysField.value = data.earlyPaymentDiscountDays ?? 0;
}).catch(() => {});
// Fetch tax rate for the selected customer
fetch(`/Invoices/GetTaxRateForCustomer?customerId=${customerId}`)
.then(r => r.ok ? r.json() : null)
.then(data => {
if (!data) return;
const taxField = document.getElementById('TaxPercent');
if (taxField) {
taxField.value = data.taxPercent ?? 0;
recalcTotals();
}
}).catch(() => {
// Fall back to client-side tax exempt check
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)}" 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}" 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);
const discountRow = document.getElementById('discountRow');
if (discountRow) {
if (discount > 0) {
document.getElementById('displayDiscount').textContent = '' + formatCurrency(discount);
discountRow.classList.remove('d-none');
} else {
discountRow.classList.add('d-none');
}
}
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>
}