Initial commit
This commit is contained in:
@@ -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 — @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 & 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,'"')}"
|
||||
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, '"')}" />
|
||||
<input type="hidden" name="InvoiceItems[${idx}].GcRecipientEmail" value="${gcRecipientEmail.replace(/"/g, '"')}" />
|
||||
<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, '"')}" 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>
|
||||
}
|
||||
Reference in New Issue
Block a user