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>
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,367 @@
@model PowderCoating.Application.DTOs.Invoice.UpdateInvoiceDto
@{
ViewData["Title"] = "Edit Invoice";
ViewData["PageIcon"] = "bi-pencil-square";
var invoiceNumber = ViewBag.InvoiceNumber as string;
var invoiceId = (int)(ViewBag.InvoiceId ?? 0);
var jobNumber = ViewBag.JobNumber as string;
var customerName = ViewBag.CustomerName as string;
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<p class="text-muted mb-0 small">
@invoiceNumber
@if (!string.IsNullOrEmpty(jobNumber)) { <span>&mdash; Job #@jobNumber</span> }
@if (!string.IsNullOrEmpty(customerName)) { <span>&mdash; @customerName</span> }
</p>
</div>
<a asp-action="Details" asp-route-id="@invoiceId" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to Invoice
</a>
</div>
<form asp-action="Edit" asp-route-id="@invoiceId" method="post" id="invoiceForm">
@Html.AntiForgeryToken()
<div class="row g-4">
<!-- LEFT: Main form -->
<div class="col-lg-8">
<!-- 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 and the reference for payment terms. Due Date drives overdue status and A/R aging. Payment Terms prints on the invoice — changing it here only affects this invoice. Only Draft invoices can be edited; sending locks the invoice.">
<i class="bi bi-question-circle"></i>
</a>
</div>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label asp-for="InvoiceDate" class="form-label fw-semibold">Invoice Date <span class="text-danger">*</span></label>
<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">
<label asp-for="DueDate" class="form-label fw-semibold">Due Date</label>
<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">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">
<label asp-for="Terms" class="form-label fw-semibold">Payment Terms</label>
<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. Qty × Unit Price = Total per line; you can also override Total directly. Color is optional and appears under the description when printed. Add manual lines for charges not in the original job (e.g., rush fee, pickup charge).">
<i class="bi bi-question-circle"></i>
</a>
</div>
<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 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].DisplayOrder" class="display-order-input" value="@(i + 1)" />
<input type="hidden" name="InvoiceItems[@i].RevenueAccountId" value="@item.RevenueAccountId" />
<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 in the app and are 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. Tax % is applied to (Subtotal Discount). Both default from the company settings but can be overridden for this 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>Save Changes
</button>
<a asp-action="Details" asp-route-id="@invoiceId" class="btn btn-outline-secondary">
<i class="bi bi-x-circle me-1"></i>Cancel
</a>
</div>
<div class="card-footer border-0 pt-0">
<div class="alert alert-warning mb-0 small py-2">
<i class="bi bi-exclamation-triangle me-1"></i>
Only <strong>Draft</strong> invoices can be edited. Send the invoice to lock it.
</div>
</div>
</div>
</div>
</div>
</form>
@section Scripts {
<script>
let itemCount = @Model.InvoiceItems.Count;
function addLineItem() {
const idx = itemCount++;
const tbody = document.getElementById('lineItemsBody');
const row = document.createElement('tr');
row.className = 'line-item-row';
row.dataset.index = idx;
row.innerHTML = `
<td>
<input type="hidden" name="InvoiceItems[${idx}].SourceJobItemId" value="" />
<input type="hidden" name="InvoiceItems[${idx}].DisplayOrder" class="display-order-input" value="${idx + 1}" />
<input type="hidden" name="InvoiceItems[${idx}].RevenueAccountId" value="" />
<input type="text" name="InvoiceItems[${idx}].Description"
class="form-control form-control-sm" placeholder="Description" required />
<input type="text" name="InvoiceItems[${idx}].Notes"
class="form-control form-control-sm mt-1" placeholder="Notes (optional)" />
</td>
<td class="text-center">
<input type="number" name="InvoiceItems[${idx}].Quantity"
class="form-control form-control-sm text-center qty-input"
value="1" 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="0.00" 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="0.00" 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" />
</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);
}
recalcTotals();
</script>
}
@@ -0,0 +1,196 @@
@using PowderCoating.Application.DTOs.Common
@using PowderCoating.Application.DTOs.Invoice
@using PowderCoating.Core.Enums
@using PowderCoating.Web.Controllers
@model PagedResult<InvoiceListDto>
@{
ViewData["Title"] = "Invoices";
ViewData["PageIcon"] = "bi-receipt";
ViewData["PageHelpTitle"] = "Invoices";
ViewData["PageHelpContent"] = "Invoices are created from completed jobs and sent to the customer for payment. Lifecycle: Draft (editable) → Sent (locked, awaiting payment) → Partially Paid / Paid. Overdue = past due date with a balance still owed. Outstanding shows the total A/R balance across all unpaid invoices currently on screen. Use Void to cancel without deleting history.";
var searchTerm = ViewBag.SearchTerm as string;
var statusFilter = ViewBag.StatusFilter as InvoiceStatus?;
var outstandingOnly = (bool)(ViewBag.OutstandingOnly ?? false);
var thisMonthOnly = (bool)(ViewBag.ThisMonthOnly ?? false);
}
@{
var _outstanding = Model.Items.Where(i => i.Status != InvoiceStatus.Paid && i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).Sum(i => i.BalanceDue);
var _collected = Model.Items.Sum(i => i.AmountPaid);
var _overdue = Model.Items.Count(i => i.IsOverdue);
}
<div class="pcl-metric-strip">
<div class="pcl-metric-strip-cell">
@await Html.PartialAsync("_Metric", (Label: "TOTAL", Value: Model.TotalCount.ToString(), Delta: (string?)null, DeltaDir: (string?)null))
</div>
<div class="pcl-metric-strip-cell">
@await Html.PartialAsync("_Metric", (Label: "OUTSTANDING", Value: _outstanding.ToString("C0"), Delta: (string?)null, DeltaDir: (string?)null))
</div>
<div class="pcl-metric-strip-cell">
@await Html.PartialAsync("_Metric", (Label: "OVERDUE", Value: _overdue.ToString(), Delta: (string?)null, DeltaDir: (string?)null))
</div>
<div class="pcl-metric-strip-cell">
@await Html.PartialAsync("_Metric", (Label: "COLLECTED", Value: _collected.ToString("C0"), Delta: (string?)null, DeltaDir: (string?)null))
</div>
</div>
@if (TempData["Success"] != null)
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="bi bi-check-circle me-2"></i>@TempData["Success"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@if (TempData["Error"] != null)
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i>@TempData["Error"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
<div class="card border-0 shadow-sm">
<div class="card-header border-0 py-3">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-start align-items-lg-center gap-3">
<form asp-action="Index" method="get" class="d-flex flex-nowrap gap-2 align-items-center">
<input type="hidden" name="sortColumn" value="@ViewBag.SortColumn" />
<input type="hidden" name="sortDirection" value="@ViewBag.SortDirection" />
<input type="hidden" name="pageSize" value="@Model.PageSize" />
@if (outstandingOnly) { <input type="hidden" name="outstandingOnly" value="true" /> }
@if (thisMonthOnly) { <input type="hidden" name="thisMonthOnly" value="true" /> }
<div class="input-group" style="max-width:280px; min-width:180px;">
<span class="input-group-text border-end-0"><i class="bi bi-search text-muted"></i></span>
<input type="text" name="searchTerm" class="form-control border-start-0"
placeholder="Search invoices..." value="@searchTerm">
</div>
<select class="form-select" name="statusFilter" style="width:auto;">
<option value="">All Statuses</option>
@foreach (InvoiceStatus s in Enum.GetValues(typeof(InvoiceStatus)))
{
<option value="@((int)s)" selected="@(statusFilter == s)">@InvoicesController.GetStatusDisplay(s)</option>
}
</select>
<button type="submit" class="btn btn-primary"><i class="bi bi-search"></i></button>
@if (!string.IsNullOrEmpty(searchTerm) || statusFilter.HasValue || outstandingOnly || thisMonthOnly)
{
<a asp-action="Index" class="btn btn-outline-secondary">Clear</a>
}
@if (outstandingOnly)
{
<span class="badge bg-info text-dark fs-6 fw-normal">
<i class="bi bi-funnel-fill me-1"></i>Outstanding A/R
</span>
}
@if (thisMonthOnly && statusFilter == InvoiceStatus.Paid)
{
<span class="badge bg-success fs-6 fw-normal">
<i class="bi bi-funnel-fill me-1"></i>Paid — @DateTime.Now.ToString("MMMM yyyy")
</span>
}
else if (thisMonthOnly)
{
<span class="badge bg-info text-dark fs-6 fw-normal">
<i class="bi bi-funnel-fill me-1"></i>@DateTime.Now.ToString("MMMM yyyy")
</span>
}
</form>
<a asp-action="Create" class="btn btn-primary text-nowrap">
<i class="bi bi-plus-circle me-2"></i>New Invoice
</a>
</div>
</div>
<div class="card-body p-0">
@if (Model != null && Model.Items.Any())
{
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th sortable="InvoiceNumber" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Invoice #</th>
<th>Customer</th>
<th>Job #</th>
<th sortable="Status" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Status</th>
<th sortable="InvoiceDate" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Date</th>
<th sortable="DueDate" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Due</th>
<th sortable="Total" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection" class="text-end">Total</th>
<th sortable="BalanceDue" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection" class="text-end">Balance Due</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var inv in Model.Items)
{
var rowClass = inv.IsOverdue ? "table-danger" : "";
<tr class="@rowClass" style="cursor:pointer;"
onclick="window.location='@Url.Action("Details", "Invoices", new { id = inv.Id })'">
<td><strong>@inv.InvoiceNumber</strong></td>
<td>@inv.CustomerName</td>
<td>
@if (inv.JobId.HasValue)
{
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@inv.JobId"
class="text-decoration-none" onclick="event.stopPropagation()">
@inv.JobNumber
</a>
}
else
{
<span class="badge bg-success-subtle text-success">Merch</span>
}
</td>
<td>
@await Html.PartialAsync("_StatusChip", (Kind: StatusChipHelper.InvoiceStatus(inv.Status), Text: InvoicesController.GetStatusDisplay(inv.Status)))
</td>
<td>@inv.InvoiceDate.ToString("MM/dd/yyyy")</td>
<td class="@(inv.IsOverdue ? "fw-bold text-danger" : "")">
@(inv.DueDate.HasValue ? inv.DueDate.Value.ToString("MM/dd/yyyy") : "—")
</td>
<td class="text-end">@inv.Total.ToString("C")</td>
<td class="text-end @(inv.BalanceDue > 0 ? "fw-semibold" : "text-muted")">
@inv.BalanceDue.ToString("C")
</td>
<td onclick="event.stopPropagation()">
<div class="d-flex gap-1">
<a asp-action="Details" asp-route-id="@inv.Id"
class="btn btn-sm btn-outline-primary" title="View">
<i class="bi bi-eye"></i>
</a>
<a asp-action="DownloadPdf" asp-route-id="@inv.Id"
class="btn btn-sm btn-outline-secondary" title="Download PDF">
<i class="bi bi-file-pdf"></i>
</a>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="px-3">
@await Html.PartialAsync("_Pagination", Model)
</div>
}
else
{
<div class="text-center py-5">
<i class="bi bi-receipt" style="font-size:3rem; color:#94a3b8;"></i>
<p class="mt-3 text-muted">No invoices found.</p>
<a asp-action="Create" class="btn btn-primary mt-2">
<i class="bi bi-plus-circle me-2"></i>Create First Invoice
</a>
</div>
}
</div>
</div>
@section Scripts {
<script>
function changePageSize(size) {
const url = new URL(window.location.href);
url.searchParams.set('pageSize', size);
url.searchParams.set('pageNumber', '1');
window.location.href = url.toString();
}
</script>
}
@@ -0,0 +1,237 @@
@model PowderCoating.Web.ViewModels.OnlinePaymentsViewModel
@{
ViewData["Title"] = "Online Payment Reconciliation";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="mb-0"><i class="bi bi-credit-card me-2 text-primary"></i>Online Payment Reconciliation</h2>
<small class="text-muted">Stripe Connect payments, refunds, and chargebacks</small>
</div>
</div>
@* Date range filter *@
<div class="card border-0 shadow-sm mb-4">
<div class="card-body py-3">
<form method="get" class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label mb-1 small fw-semibold">From</label>
<input type="date" name="from" class="form-control form-control-sm"
value="@Model.From.ToString("yyyy-MM-dd")" />
</div>
<div class="col-auto">
<label class="form-label mb-1 small fw-semibold">To</label>
<input type="date" name="to" class="form-control form-control-sm"
value="@Model.To.ToString("yyyy-MM-dd")" />
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-search me-1"></i>Filter
</button>
</div>
</form>
</div>
</div>
@* Summary cards *@
<div class="row g-3 mb-4">
<div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="text-muted small mb-1">Total Collected Online</div>
<div class="fs-4 fw-bold text-success">@Model.TotalCollected.ToString("C")</div>
<div class="text-muted small">@Model.Invoices.Count invoice(s)</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="text-muted small mb-1">Total Refunded</div>
<div class="fs-4 fw-bold text-danger">@Model.TotalRefunded.ToString("C")</div>
<div class="text-muted small">@Model.Refunds.Count refund(s)</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="text-muted small mb-1">Net Revenue</div>
<div class="fs-4 fw-bold @(Model.NetRevenue >= 0 ? "text-success" : "text-danger")">
@Model.NetRevenue.ToString("C")
</div>
<div class="text-muted small">After refunds</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="text-muted small mb-1">Surcharges Collected</div>
<div class="fs-4 fw-bold text-secondary">@Model.SurchargesCollected.ToString("C")</div>
<div class="text-muted small">Convenience fees</div>
</div>
</div>
</div>
</div>
@* Online Payments Table *@
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
<h6 class="mb-0 fw-semibold"><i class="bi bi-check-circle me-2 text-success"></i>Payments Received</h6>
<span class="badge bg-success-subtle text-success">@Model.Invoices.Count</span>
</div>
<div class="card-body p-0">
@if (!Model.Invoices.Any())
{
<div class="text-center text-muted py-4">No online payments in this period.</div>
}
else
{
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th>Invoice #</th>
<th>Customer</th>
<th class="text-end">Gross Collected</th>
<th class="text-end">Surcharge</th>
<th class="text-end">Net</th>
<th>Date</th>
<th>Status</th>
<th>Payment Intent</th>
</tr>
</thead>
<tbody>
@foreach (var inv in Model.Invoices)
{
var net = inv.OnlineAmountPaid;
var gross = inv.OnlineAmountPaid + inv.OnlineSurchargeCollected;
var dateDisplay = inv.PaidDate.HasValue ? inv.PaidDate.Value.ToString("MMM d, yyyy") : (inv.UpdatedAt?.ToString("MMM d, yyyy") ?? "—");
var statusClass = inv.OnlinePaymentStatus switch
{
PowderCoating.Core.Enums.OnlinePaymentStatus.Paid => "bg-success-subtle text-success",
PowderCoating.Core.Enums.OnlinePaymentStatus.PartiallyPaid => "bg-warning-subtle text-warning",
PowderCoating.Core.Enums.OnlinePaymentStatus.Refunded => "bg-danger-subtle text-danger",
_ => "bg-secondary-subtle text-secondary"
};
var customerName = inv.Customer != null
? (inv.Customer.CompanyName ?? $"{inv.Customer.ContactFirstName} {inv.Customer.ContactLastName}".Trim())
: "—";
<tr>
<td>
<a asp-action="Details" asp-route-id="@inv.Id" class="fw-semibold text-decoration-none">
@inv.InvoiceNumber
</a>
</td>
<td>@customerName</td>
<td class="text-end">@gross.ToString("C")</td>
<td class="text-end text-muted">@inv.OnlineSurchargeCollected.ToString("C")</td>
<td class="text-end fw-semibold">@net.ToString("C")</td>
<td>@dateDisplay</td>
<td><span class="badge @statusClass">@inv.OnlinePaymentStatus</span></td>
<td>
@if (!string.IsNullOrEmpty(inv.StripePaymentIntentId))
{
<code class="small" title="@inv.StripePaymentIntentId">
@inv.StripePaymentIntentId[..Math.Min(20, inv.StripePaymentIntentId.Length)]…
</code>
}
</td>
</tr>
}
</tbody>
<tfoot class="table-light fw-semibold">
<tr>
<td colspan="2">Totals</td>
<td class="text-end">@((Model.TotalCollected + Model.SurchargesCollected).ToString("C"))</td>
<td class="text-end">@Model.SurchargesCollected.ToString("C")</td>
<td class="text-end">@Model.TotalCollected.ToString("C")</td>
<td colspan="3"></td>
</tr>
</tfoot>
</table>
</div>
}
</div>
</div>
@* Refunds Table *@
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
<h6 class="mb-0 fw-semibold"><i class="bi bi-arrow-counterclockwise me-2 text-danger"></i>Refunds & Chargebacks</h6>
<span class="badge bg-danger-subtle text-danger">@Model.Refunds.Count</span>
</div>
<div class="card-body p-0">
@if (!Model.Refunds.Any())
{
<div class="text-center text-muted py-4">No refunds in this period.</div>
}
else
{
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th>Invoice #</th>
<th>Customer</th>
<th class="text-end">Amount</th>
<th>Date</th>
<th>Status</th>
<th>Stripe Reference</th>
<th>Reason</th>
</tr>
</thead>
<tbody>
@foreach (var r in Model.Refunds)
{
var invNum = r.Invoice?.InvoiceNumber ?? "—";
var invId = r.Invoice?.Id;
var cust = r.Invoice?.Customer;
var custName = cust != null
? (cust.CompanyName ?? $"{cust.ContactFirstName} {cust.ContactLastName}".Trim())
: "—";
var statusClass = r.Status switch
{
PowderCoating.Core.Enums.RefundStatus.Issued => "bg-success-subtle text-success",
PowderCoating.Core.Enums.RefundStatus.Pending => "bg-warning-subtle text-warning",
PowderCoating.Core.Enums.RefundStatus.Cancelled => "bg-secondary-subtle text-secondary",
_ => "bg-secondary-subtle text-secondary"
};
<tr>
<td>
@if (invId.HasValue)
{
<a asp-action="Details" asp-route-id="@invId" class="fw-semibold text-decoration-none">@invNum</a>
}
else
{
@invNum
}
</td>
<td>@custName</td>
<td class="text-end text-danger fw-semibold">@r.Amount.ToString("C")</td>
<td>@r.RefundDate.ToString("MMM d, yyyy")</td>
<td><span class="badge @statusClass">@r.Status</span></td>
<td>
@if (!string.IsNullOrEmpty(r.Reference))
{
<code class="small" title="@r.Reference">@r.Reference[..Math.Min(24, r.Reference.Length)]</code>
}
</td>
<td class="text-muted small">@r.Reason</td>
</tr>
}
</tbody>
<tfoot class="table-light fw-semibold">
<tr>
<td colspan="2">Total Refunded</td>
<td class="text-end text-danger">@Model.TotalRefunded.ToString("C")</td>
<td colspan="4"></td>
</tr>
</tfoot>
</table>
</div>
}
</div>
</div>