Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,753 @@
@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'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()
@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>
}
<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="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.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<input asp-for="Terms" class="form-control" placeholder="e.g. Net 30" />
</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>
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>
}