Compare commits

...

7 Commits

Author SHA1 Message Date
spouliot 627d723c95 Merge dev into master: invoice pills, Bills mobile, payment suppress-notify, scroll fix, — fixes 2026-06-16 20:35:42 -04:00
spouliot 0498decfb0 Fix quote/job create page jumping to bottom on fresh load
The wizard scroll-restore saved scroll position on form submit but never
cleared it if the server redirected to a success page. Next fresh visit
to the same URL found the stale sessionStorage key and jumped down.

Fix: track whether the page unload was caused by our own form submit.
On pagehide for any other reason (nav link, success redirect), remove
the key so it never fires on a clean page load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 20:24:47 -04:00
spouliot 2fae9aefad Fix Bills detail page horizontal scrolling on mobile
Wrap Line Items and Payment History tables in table-responsive so they
scroll horizontally rather than overflowing the viewport. Expenses detail
page uses a definition list layout and was not affected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 20:12:00 -04:00
spouliot 2c179bc892 Add mobile card view to Bills/Expenses list page
Wraps the desktop table in table-responsive to fix horizontal scrolling,
and adds a mobile-card-view section matching the pattern used on Invoices,
PurchaseOrders, and other list pages. Cards show type, number, vendor,
status, date, due date, amount, balance due, and memo/account.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 18:48:48 -04:00
spouliot deb248b2a6 Add "Don't notify customer" option to Record Payment modal
Adds SuppressNotification to RecordPaymentDto and a checkbox to the
modal. When checked, the payment is fully recorded but NotifyPaymentReceivedAsync
is skipped — useful for historical imports or cases where the customer
should not receive an email.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 18:12:25 -04:00
spouliot eb8fc8b6d0 Add status-group pills to Invoices list, default to Unpaid
Bare /Invoices now redirects to statusGroup=unpaid (Draft, Sent, Overdue)
so the list is immediately actionable. Four pills — All, Unpaid, Partial,
Paid — mirror the Jobs page pattern with live badge counts. The existing
status dropdown and outstanding/thisMonth flags are preserved for
dashboard deep-links.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 16:42:43 -04:00
spouliot 4f039b8281 Fix &mdash; HTML entities rendering as literal text in JS textContent
textContent treats &mdash; as a plain string; replaced with innerHTML
for static dash placeholders, and — JS escape where user input
is concatenated. Also removed a dead textContent line in timeclock-kiosk.js
that was immediately overwritten by innerHTML on the next line.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 16:37:37 -04:00
14 changed files with 304 additions and 135 deletions
@@ -37,6 +37,7 @@ public class PaymentDtos
public string? Reference { get; set; } public string? Reference { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
public int? DepositAccountId { get; set; } public int? DepositAccountId { get; set; }
public bool SuppressNotification { get; set; }
} }
public class EditPaymentDto public class EditPaymentDto
@@ -82,14 +82,15 @@ public class InvoicesController : Controller
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/// <summary> /// <summary>
/// Displays the paginated invoice list with multi-mode filtering. The filter cascade handles /// Displays the paginated invoice list with multi-mode filtering. The filter cascade handles
/// nine combinations of overdue/outstanding/thisMonth flags with status and search term so the /// statusGroup pills (unpaid/partial/paid/all) plus legacy flag combinations (overdue/outstanding/thisMonth)
/// database receives a single targeted predicate — no full-table load then in-memory LINQ. /// so the database receives a single targeted predicate — no full-table load then in-memory LINQ.
/// Balance-due sort is computed in the ORDER BY expression rather than a stored column because /// Balance-due sort is computed in the ORDER BY expression rather than a stored column because
/// balance = Total AmountPaid CreditApplied GiftCertificateRedeemed changes on every payment. /// balance = Total AmountPaid CreditApplied GiftCertificateRedeemed changes on every payment.
/// </summary> /// </summary>
public async Task<IActionResult> Index( public async Task<IActionResult> Index(
string? searchTerm, string? searchTerm,
InvoiceStatus? statusFilter, InvoiceStatus? statusFilter,
string? statusGroup,
string? sortColumn, string? sortColumn,
string sortDirection = "desc", string sortDirection = "desc",
bool outstandingOnly = false, bool outstandingOnly = false,
@@ -100,6 +101,11 @@ public class InvoicesController : Controller
{ {
try try
{ {
// Default landing: show unpaid invoices so the list is immediately actionable.
if (string.IsNullOrEmpty(statusGroup) && !statusFilter.HasValue &&
string.IsNullOrEmpty(searchTerm) && !outstandingOnly && !thisMonthOnly && !overdueOnly)
return RedirectToAction("Index", new { statusGroup = "unpaid" });
var today = DateTime.Today; var today = DateTime.Today;
var startOfMonth = new DateTime(today.Year, today.Month, 1); var startOfMonth = new DateTime(today.Year, today.Month, 1);
var endOfMonth = startOfMonth.AddMonths(1); var endOfMonth = startOfMonth.AddMonths(1);
@@ -116,7 +122,18 @@ public class InvoicesController : Controller
System.Linq.Expressions.Expression<Func<Invoice, bool>>? filter = null; System.Linq.Expressions.Expression<Func<Invoice, bool>>? filter = null;
if (overdueOnly) // Status-group pills take priority over the dropdown and legacy flags.
if (!string.IsNullOrEmpty(statusGroup))
{
filter = statusGroup switch
{
"unpaid" => i => i.Status == InvoiceStatus.Draft || i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.Overdue,
"partial" => i => i.Status == InvoiceStatus.PartiallyPaid,
"paid" => i => i.Status == InvoiceStatus.Paid,
_ => null // "all" — no predicate
};
}
else if (overdueOnly)
{ {
filter = i => (i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.PartiallyPaid || i.Status == InvoiceStatus.Overdue) filter = i => (i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.PartiallyPaid || i.Status == InvoiceStatus.Overdue)
&& i.DueDate.HasValue && i.DueDate.Value < today; && i.DueDate.HasValue && i.DueDate.Value < today;
@@ -215,12 +232,20 @@ public class InvoicesController : Controller
ViewBag.SearchTerm = searchTerm; ViewBag.SearchTerm = searchTerm;
ViewBag.StatusFilter = statusFilter; ViewBag.StatusFilter = statusFilter;
ViewBag.StatusGroup = statusGroup;
ViewBag.OutstandingOnly = outstandingOnly; ViewBag.OutstandingOnly = outstandingOnly;
ViewBag.ThisMonthOnly = thisMonthOnly; ViewBag.ThisMonthOnly = thisMonthOnly;
ViewBag.OverdueOnly = overdueOnly; ViewBag.OverdueOnly = overdueOnly;
ViewBag.SortColumn = gridRequest.SortColumn; ViewBag.SortColumn = gridRequest.SortColumn;
ViewBag.SortDirection = gridRequest.SortDirection; ViewBag.SortDirection = gridRequest.SortDirection;
// Pill badge counts — always global (not scoped to current filter/page)
ViewBag.UnpaidCount = await _unitOfWork.Invoices.CountAsync(i =>
i.Status == InvoiceStatus.Draft || i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.Overdue);
ViewBag.PartialCount = await _unitOfWork.Invoices.CountAsync(i => i.Status == InvoiceStatus.PartiallyPaid);
ViewBag.PaidCount = await _unitOfWork.Invoices.CountAsync(i => i.Status == InvoiceStatus.Paid);
ViewBag.AllCount = await _unitOfWork.Invoices.CountAsync();
return View(pagedResult); return View(pagedResult);
} }
catch (Exception ex) catch (Exception ex)
@@ -1310,14 +1335,17 @@ public class InvoicesController : Controller
}); // end ExecuteInTransactionAsync }); // end ExecuteInTransactionAsync
// Notify (non-blocking) // Notify (non-blocking) — skipped if user explicitly suppressed it
try if (!dto.SuppressNotification)
{ {
await _notificationService.NotifyPaymentReceivedAsync(invoice, payment); try
} {
catch (Exception notifyEx) await _notificationService.NotifyPaymentReceivedAsync(invoice, payment);
{ }
_logger.LogWarning(notifyEx, "Payment recorded but notification failed"); catch (Exception notifyEx)
{
_logger.LogWarning(notifyEx, "Payment recorded but notification failed");
}
} }
@@ -113,7 +113,7 @@
const preview = document.getElementById('announcementPreview'); const preview = document.getElementById('announcementPreview');
preview.className = 'alert mb-0 ' + (typeMap[type] || 'alert-info'); preview.className = 'alert mb-0 ' + (typeMap[type] || 'alert-info');
document.getElementById('previewTitle').textContent = document.getElementById('Title').value || 'Title'; document.getElementById('previewTitle').textContent = document.getElementById('Title').value || 'Title';
document.getElementById('previewMessage').textContent = ' &mdash; ' + (document.getElementById('Message').value || 'Message'); document.getElementById('previewMessage').textContent = '\u2014' + (document.getElementById('Message').value || 'Message');
} }
document.getElementById('Type')?.addEventListener('change', updatePreview); document.getElementById('Type')?.addEventListener('change', updatePreview);
document.getElementById('Title')?.addEventListener('input', updatePreview); document.getElementById('Title')?.addEventListener('input', updatePreview);
@@ -598,7 +598,7 @@
const modal = bootstrap.Modal.getInstance(document.getElementById('scanReceiptModal')); const modal = bootstrap.Modal.getInstance(document.getElementById('scanReceiptModal'));
if (modal) modal.hide(); if (modal) modal.hide();
statusEl.textContent = 'Scan complete &mdash; review and adjust as needed.'; statusEl.innerHTML = 'Scan complete &mdash; review and adjust as needed.';
} catch (e) { } catch (e) {
statusEl.textContent = 'Error connecting to AI service.'; statusEl.textContent = 'Error connecting to AI service.';
} finally { } finally {
@@ -127,6 +127,7 @@
<div class="card shadow-sm mb-4"> <div class="card shadow-sm mb-4">
<div class="card-header fw-semibold">Line Items</div> <div class="card-header fw-semibold">Line Items</div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0"> <table class="table table-sm mb-0">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
@@ -169,6 +170,7 @@
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
</div>
</div> </div>
</div> </div>
@@ -210,6 +212,7 @@
</a> </a>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0"> <table class="table table-sm mb-0">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
@@ -253,6 +256,7 @@
} }
</tbody> </tbody>
</table> </table>
</div>
</div> </div>
</div> </div>
} }
+165 -78
View File
@@ -92,94 +92,181 @@
{ {
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body p-0"> <div class="card-body p-0">
<table class="table table-hover mb-0"> <div class="table-responsive">
<thead class="table-light"> <table class="table table-hover mb-0">
<tr> <thead class="table-light">
<th style="width:90px">Type</th> <tr>
<th>Number</th> <th style="width:90px">Type</th>
<th>Vendor</th> <th>Number</th>
<th>Memo / Account</th> <th>Vendor</th>
<th>Date</th> <th>Memo / Account</th>
<th>Due Date</th> <th>Date</th>
<th>Status</th> <th>Due Date</th>
<th class="text-end">Amount</th> <th>Status</th>
<th class="text-end">Balance Due</th> <th class="text-end">Amount</th>
<th></th> <th class="text-end">Balance Due</th>
</tr> <th></th>
</thead> </tr>
<tbody> </thead>
<tbody>
@foreach (var entry in Model)
{
<tr class="@(entry.IsOverdue ? "table-warning" : "")">
<td>
@if (entry.EntryType == "Bill")
{
<span class="badge bg-primary-subtle text-primary border border-primary-subtle">
<i class="bi bi-file-text me-1"></i>Bill
</span>
}
else
{
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">
<i class="bi bi-receipt me-1"></i>Expense
</span>
}
</td>
<td>
@if (entry.EntryType == "Bill")
{
<a asp-controller="Bills" asp-action="Details" asp-route-id="@entry.Id"
class="fw-medium text-decoration-none">@entry.Number</a>
}
else
{
<a asp-controller="Expenses" asp-action="Details" asp-route-id="@entry.Id"
class="fw-medium text-decoration-none">@entry.Number</a>
}
</td>
<td>@entry.VendorName</td>
<td class="text-muted small">
@(entry.EntryType == "Bill" ? entry.Memo : entry.AccountName)
@if (entry.HasReceipt)
{
<i class="bi bi-paperclip ms-1" title="Has receipt"></i>
}
</td>
<td>@entry.Date.ToString("MMM d, yyyy")</td>
<td>
@if (entry.DueDate.HasValue)
{
<span class="@(entry.IsOverdue ? "text-danger fw-medium" : "")">
@entry.DueDate.Value.ToString("MMM d, yyyy")
@if (entry.IsOverdue) { <i class="bi bi-exclamation-circle ms-1"></i> }
</span>
}
else if (entry.EntryType == "Expense")
{
<span class="text-muted">&mdash;</span>
}
</td>
<td><span class="badge bg-@entry.StatusColor">@entry.StatusLabel</span></td>
<td class="text-end">@entry.Total.ToString("C")</td>
<td class="text-end fw-medium @(entry.BalanceDue > 0 ? "text-danger" : "text-muted")">
@Html.Raw(entry.EntryType == "Bill" ? entry.BalanceDue.ToString("C") : "&mdash;")
</td>
<td>
@if (entry.EntryType == "Bill")
{
<a asp-controller="Bills" asp-action="Details" asp-route-id="@entry.Id"
class="btn btn-sm btn-outline-primary"><i class="bi bi-eye"></i></a>
}
else
{
<a asp-controller="Expenses" asp-action="Details" asp-route-id="@entry.Id"
class="btn btn-sm btn-outline-secondary"><i class="bi bi-eye"></i></a>
}
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var entry in Model) @foreach (var entry in Model)
{ {
<tr class="@(entry.IsOverdue ? "table-warning" : "")"> var isBill = entry.EntryType == "Bill";
<td> var detailUrl = isBill
@if (entry.EntryType == "Bill") ? Url.Action("Details", "Bills", new { id = entry.Id })
{ : Url.Action("Details", "Expenses", new { id = entry.Id });
<span class="badge bg-primary-subtle text-primary border border-primary-subtle"> <div class="mobile-data-card" onclick="window.location='@detailUrl'"
<i class="bi bi-file-text me-1"></i>Bill style="@(entry.IsOverdue ? "border-left: 3px solid #f59e0b;" : "")">
</span> <div class="mobile-card-header">
} <div class="mobile-card-icon" style="background: linear-gradient(135deg, @(isBill ? "#3b82f6 0%, #2563eb" : "#6b7280 0%, #4b5563") 100%);">
else <i class="bi @(isBill ? "bi-file-text" : "bi-receipt")"></i>
{ </div>
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle"> <div class="mobile-card-title">
<i class="bi bi-receipt me-1"></i>Expense <h6>@entry.Number</h6>
</span> <small>@entry.VendorName</small>
} </div>
</td> <div class="ms-auto">
<td> @if (isBill)
@if (entry.EntryType == "Bill") {
{ <span class="badge bg-primary-subtle text-primary border border-primary-subtle">Bill</span>
<a asp-controller="Bills" asp-action="Details" asp-route-id="@entry.Id" }
class="fw-medium text-decoration-none">@entry.Number</a> else
} {
else <span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">Expense</span>
{ }
<a asp-controller="Expenses" asp-action="Details" asp-route-id="@entry.Id" </div>
class="fw-medium text-decoration-none">@entry.Number</a> </div>
} <div class="mobile-card-body">
</td> <div class="mobile-card-row">
<td>@entry.VendorName</td> <span class="mobile-card-label">Status</span>
<td class="text-muted small"> <span class="mobile-card-value"><span class="badge bg-@entry.StatusColor">@entry.StatusLabel</span></span>
@(entry.EntryType == "Bill" ? entry.Memo : entry.AccountName) </div>
@if (entry.HasReceipt) <div class="mobile-card-row">
{ <span class="mobile-card-label">Date</span>
<i class="bi bi-paperclip ms-1" title="Has receipt"></i> <span class="mobile-card-value">@entry.Date.ToString("MMM d, yyyy")</span>
} </div>
</td>
<td>@entry.Date.ToString("MMM d, yyyy")</td>
<td>
@if (entry.DueDate.HasValue) @if (entry.DueDate.HasValue)
{ {
<span class="@(entry.IsOverdue ? "text-danger fw-medium" : "")"> <div class="mobile-card-row">
@entry.DueDate.Value.ToString("MMM d, yyyy") <span class="mobile-card-label">Due</span>
@if (entry.IsOverdue) { <i class="bi bi-exclamation-circle ms-1"></i> } <span class="mobile-card-value @(entry.IsOverdue ? "text-danger fw-medium" : "")">
</span> @entry.DueDate.Value.ToString("MMM d, yyyy")
@if (entry.IsOverdue) { <i class="bi bi-exclamation-circle ms-1"></i> }
</span>
</div>
} }
else if (entry.EntryType == "Expense") <div class="mobile-card-row">
<span class="mobile-card-label">Amount</span>
<span class="mobile-card-value fw-semibold">@entry.Total.ToString("C")</span>
</div>
@if (isBill)
{ {
<span class="text-muted">&mdash;</span> <div class="mobile-card-row">
<span class="mobile-card-label">Balance Due</span>
<span class="mobile-card-value @(entry.BalanceDue > 0 ? "fw-semibold text-danger" : "text-muted")">
@entry.BalanceDue.ToString("C")
</span>
</div>
} }
</td> @{
<td><span class="badge bg-@entry.StatusColor">@entry.StatusLabel</span></td> var memoText = isBill ? entry.Memo : entry.AccountName;
<td class="text-end">@entry.Total.ToString("C")</td> }
<td class="text-end fw-medium @(entry.BalanceDue > 0 ? "text-danger" : "text-muted")"> @if (!string.IsNullOrEmpty(memoText))
@Html.Raw(entry.EntryType == "Bill" ? entry.BalanceDue.ToString("C") : "&mdash;")
</td>
<td>
@if (entry.EntryType == "Bill")
{ {
<a asp-controller="Bills" asp-action="Details" asp-route-id="@entry.Id" <div class="mobile-card-row">
class="btn btn-sm btn-outline-primary"><i class="bi bi-eye"></i></a> <span class="mobile-card-label">@(isBill ? "Memo" : "Account")</span>
<span class="mobile-card-value text-muted small">
@memoText
@if (entry.HasReceipt) { <i class="bi bi-paperclip ms-1" title="Has receipt"></i> }
</span>
</div>
} }
else </div>
{ <div class="mobile-card-footer">
<a asp-controller="Expenses" asp-action="Details" asp-route-id="@entry.Id" <a href="@detailUrl" class="btn btn-sm @(isBill ? "btn-outline-primary" : "btn-outline-secondary")"
class="btn btn-sm btn-outline-secondary"><i class="bi bi-eye"></i></a> onclick="event.stopPropagation()">
} <i class="bi bi-eye me-1"></i>View
</td> </a>
</tr> </div>
</div>
} }
</tbody> </div>
</table> </div>
</div> </div>
</div> </div>
} }
@@ -3382,7 +3382,7 @@
document.getElementById('ovenCalcToggle').addEventListener('click', function (e) { document.getElementById('ovenCalcToggle').addEventListener('click', function (e) {
e.preventDefault(); e.preventDefault();
const hidden = _calcPanel.classList.toggle('d-none'); const hidden = _calcPanel.classList.toggle('d-none');
if (!hidden) { _calcW.value = ''; _calcD.value = ''; _calcH.value = ''; _calcResult.textContent = '&mdash;'; _calcApply.disabled = true; _calcW.focus(); } if (!hidden) { _calcW.value = ''; _calcD.value = ''; _calcH.value = ''; _calcResult.innerHTML = '&mdash;'; _calcApply.disabled = true; _calcW.focus(); }
}); });
function _updateCalc() { function _updateCalc() {
@@ -3402,7 +3402,7 @@
_calcApply.disabled = false; _calcApply.disabled = false;
_calcApply.dataset.val = val; _calcApply.dataset.val = val;
} else { } else {
_calcResult.textContent = '&mdash;'; _calcResult.innerHTML = '&mdash;';
_calcApply.disabled = true; _calcApply.disabled = true;
} }
} }
@@ -3420,7 +3420,7 @@
document.getElementById('ovenModal').addEventListener('hidden.bs.modal', function () { document.getElementById('ovenModal').addEventListener('hidden.bs.modal', function () {
_calcPanel.classList.add('d-none'); _calcPanel.classList.add('d-none');
_calcW.value = ''; _calcD.value = ''; _calcH.value = ''; _calcW.value = ''; _calcD.value = ''; _calcH.value = '';
_calcResult.textContent = '&mdash;'; _calcApply.disabled = true; _calcResult.innerHTML = '&mdash;'; _calcApply.disabled = true;
}); });
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
@@ -1029,6 +1029,12 @@
<label class="form-label">Notes</label> <label class="form-label">Notes</label>
<textarea name="Notes" class="form-control" rows="2" placeholder="Optional"></textarea> <textarea name="Notes" class="form-control" rows="2" placeholder="Optional"></textarea>
</div> </div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="SuppressNotification" value="true" id="suppressNotificationCheck" />
<label class="form-check-label text-muted small" for="suppressNotificationCheck">
Don&rsquo;t notify customer
</label>
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
@@ -11,8 +11,13 @@
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."; 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 searchTerm = ViewBag.SearchTerm as string;
var statusFilter = ViewBag.StatusFilter as InvoiceStatus?; var statusFilter = ViewBag.StatusFilter as InvoiceStatus?;
var statusGroup = ViewBag.StatusGroup as string;
var outstandingOnly = (bool)(ViewBag.OutstandingOnly ?? false); var outstandingOnly = (bool)(ViewBag.OutstandingOnly ?? false);
var thisMonthOnly = (bool)(ViewBag.ThisMonthOnly ?? false); var thisMonthOnly = (bool)(ViewBag.ThisMonthOnly ?? false);
var unpaidCount = (int)(ViewBag.UnpaidCount ?? 0);
var partialCount = (int)(ViewBag.PartialCount ?? 0);
var paidCount = (int)(ViewBag.PaidCount ?? 0);
var allCount = (int)(ViewBag.AllCount ?? 0);
} }
@{ @{
@@ -52,52 +57,77 @@
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-header border-0 py-3"> <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"> <div class="d-flex flex-column gap-2">
<form asp-action="Index" method="get" class="d-flex flex-nowrap gap-2 align-items-center"> <!-- Row 1: search + dropdown + actions -->
<input type="hidden" name="sortColumn" value="@ViewBag.SortColumn" /> <div class="d-flex flex-wrap gap-2 justify-content-between align-items-center">
<input type="hidden" name="sortDirection" value="@ViewBag.SortDirection" /> <form asp-action="Index" method="get" class="d-flex flex-nowrap gap-2 align-items-center flex-grow-1" style="max-width:560px;">
<input type="hidden" name="pageSize" value="@Model.PageSize" /> <input type="hidden" name="sortColumn" value="@ViewBag.SortColumn" />
@if (outstandingOnly) { <input type="hidden" name="outstandingOnly" value="true" /> } <input type="hidden" name="sortDirection" value="@ViewBag.SortDirection" />
@if (thisMonthOnly) { <input type="hidden" name="thisMonthOnly" value="true" /> } <input type="hidden" name="pageSize" value="@Model.PageSize" />
<div class="input-group" style="max-width:280px; min-width:180px;"> @if (outstandingOnly) { <input type="hidden" name="outstandingOnly" value="true" /> }
<span class="input-group-text border-end-0"><i class="bi bi-search text-muted"></i></span> @if (thisMonthOnly) { <input type="hidden" name="thisMonthOnly" value="true" /> }
<input type="text" name="searchTerm" class="form-control border-start-0" <div class="input-group" style="min-width:180px;">
placeholder="Search invoices..." value="@searchTerm"> <span class="input-group-text border-end-0"><i class="bi bi-search text-muted"></i></span>
</div> <input type="text" name="searchTerm" class="form-control border-start-0"
<select class="form-select" name="statusFilter" style="width:auto;"> placeholder="Search invoices&hellip;" value="@searchTerm">
<option value="">All Statuses</option> </div>
@foreach (InvoiceStatus s in Enum.GetValues(typeof(InvoiceStatus))) <select class="form-select" name="statusFilter" style="width:auto;" onchange="this.form.submit()">
<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 || !string.IsNullOrEmpty(statusGroup))
{ {
<option value="@((int)s)" selected="@(statusFilter == s)">@InvoicesController.GetStatusDisplay(s)</option> <a asp-action="Index" asp-route-statusGroup="unpaid" class="btn btn-outline-secondary text-nowrap"><i class="bi bi-x-lg"></i></a>
} }
</select> </form>
<button type="submit" class="btn btn-primary"><i class="bi bi-search"></i></button> <a asp-action="Create" class="btn btn-primary text-nowrap">
@if (!string.IsNullOrEmpty(searchTerm) || statusFilter.HasValue || outstandingOnly || thisMonthOnly) <i class="bi bi-plus-circle me-2"></i>New Invoice
{ </a>
<a asp-action="Index" class="btn btn-outline-secondary">Clear</a> </div>
} <!-- Row 2: status-group pills -->
@if (outstandingOnly) <div class="pcl-pill-group">
{ <a href="@Url.Action("Index", new { statusGroup = "all" })" class="pcl-pill @(statusGroup == "all" ? "active" : "")">
All <span class="pcl-pill-count">@allCount</span>
</a>
<a href="@Url.Action("Index", new { statusGroup = "unpaid" })" class="pcl-pill @(statusGroup == "unpaid" ? "active" : "")">
Unpaid <span class="pcl-pill-count">@unpaidCount</span>
</a>
<a href="@Url.Action("Index", new { statusGroup = "partial" })" class="pcl-pill @(statusGroup == "partial" ? "active" : "")">
Partial <span class="pcl-pill-count">@partialCount</span>
</a>
<a href="@Url.Action("Index", new { statusGroup = "paid" })" class="pcl-pill @(statusGroup == "paid" ? "active" : "")">
Paid <span class="pcl-pill-count">@paidCount</span>
</a>
</div>
<!-- Legacy filter badges (outstanding A/R, this-month) -->
@if (outstandingOnly)
{
<div>
<span class="badge bg-info text-dark fs-6 fw-normal"> <span class="badge bg-info text-dark fs-6 fw-normal">
<i class="bi bi-funnel-fill me-1"></i>Outstanding A/R <i class="bi bi-funnel-fill me-1"></i>Outstanding A/R
</span> </span>
} </div>
@if (thisMonthOnly && statusFilter == InvoiceStatus.Paid) }
{ @if (thisMonthOnly && statusFilter == InvoiceStatus.Paid)
{
<div>
<span class="badge bg-success fs-6 fw-normal"> <span class="badge bg-success fs-6 fw-normal">
<i class="bi bi-funnel-fill me-1"></i>Paid &mdash; @DateTime.Now.ToString("MMMM yyyy") <i class="bi bi-funnel-fill me-1"></i>Paid &mdash; @DateTime.Now.ToString("MMMM yyyy")
</span> </span>
} </div>
else if (thisMonthOnly) }
{ else if (thisMonthOnly)
{
<div>
<span class="badge bg-info text-dark fs-6 fw-normal"> <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") <i class="bi bi-funnel-fill me-1"></i>@DateTime.Now.ToString("MMMM yyyy")
</span> </span>
} </div>
</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> </div>
<div class="card-body p-0"> <div class="card-body p-0">
@@ -2936,8 +2936,12 @@
profitEl.className = profit >= 0 ? 'text-success' : 'text-danger'; profitEl.className = profit >= 0 ? 'text-success' : 'text-danger';
document.getElementById('costingMargin').textContent = `${d.grossMargin}%`; document.getElementById('costingMargin').textContent = `${d.grossMargin}%`;
document.getElementById('costingQuotedMargin').textContent = const quotedMarginEl = document.getElementById('costingQuotedMargin');
d.quotedPrice > 0 ? `${d.quotedMargin}% (quoted ${fmt(d.quotedPrice)})` : '&mdash;'; if (d.quotedPrice > 0) {
quotedMarginEl.textContent = `${d.quotedMargin}% (quoted ${fmt(d.quotedPrice)})`;
} else {
quotedMarginEl.innerHTML = '&mdash;';
}
// Powder detail lines // Powder detail lines
const pBody = document.getElementById('powderLines'); const pBody = document.getElementById('powderLines');
@@ -168,7 +168,7 @@
} catch (e) { } catch (e) {
document.getElementById('loadingState').classList.add('d-none'); document.getElementById('loadingState').classList.add('d-none');
document.getElementById('errorMessage').textContent = 'Network error &mdash; please try again.'; document.getElementById('errorMessage').innerHTML = 'Network error &mdash; please try again.';
document.getElementById('errorState').classList.remove('d-none'); document.getElementById('errorState').classList.remove('d-none');
} }
} }
@@ -225,7 +225,7 @@
} catch (e) { } catch (e) {
document.getElementById('loadingState').classList.add('d-none'); document.getElementById('loadingState').classList.add('d-none');
document.getElementById('errorMessage').textContent = 'Network error &mdash; please try again.'; document.getElementById('errorMessage').innerHTML = 'Network error &mdash; please try again.';
document.getElementById('errorState').classList.remove('d-none'); document.getElementById('errorState').classList.remove('d-none');
} }
} }
@@ -91,13 +91,23 @@ document.addEventListener('DOMContentLoaded', () => {
ownerForm.addEventListener('submit', writeHiddenFields, { capture: true }); ownerForm.addEventListener('submit', writeHiddenFields, { capture: true });
// Save scroll position before the form causes a full-page reload so we can // Save scroll position before the form causes a full-page reload so we can
// restore it after the server redirects back to this page. Key is path-specific // restore it after the server redirects back to this page on a validation error.
// so navigating away and back doesn't restore a stale position. // Key is path-specific; cleared on pagehide unless we're leaving via a submit so
// a fresh navigation to this page never restores a stale position.
const scrollKey = 'wizardScrollY:' + location.pathname; const scrollKey = 'wizardScrollY:' + location.pathname;
let wizardSubmitting = false;
ownerForm.addEventListener('submit', () => { ownerForm.addEventListener('submit', () => {
wizardSubmitting = true;
sessionStorage.setItem(scrollKey, String(Math.round(window.scrollY))); sessionStorage.setItem(scrollKey, String(Math.round(window.scrollY)));
}, { capture: true }); }, { capture: true });
// If the page unloads for any reason other than our own form submit (e.g. the
// user clicks a nav link or the server redirects to a success page), discard the
// saved position so it doesn't fire on the next fresh visit.
window.addEventListener('pagehide', () => {
if (!wizardSubmitting) sessionStorage.removeItem(scrollKey);
});
// Restore on load — fire after layout is painted so scrollTo lands correctly. // Restore on load — fire after layout is painted so scrollTo lands correctly.
const savedY = sessionStorage.getItem(scrollKey); const savedY = sessionStorage.getItem(scrollKey);
if (savedY !== null) { if (savedY !== null) {
@@ -123,7 +123,6 @@
var todayLine = result.dailyTotal.toFixed(2) + ' hrs today (' + result.segmentCount + (result.segmentCount === 1 ? ' segment' : ' segments') + ')'; var todayLine = result.dailyTotal.toFixed(2) + ' hrs today (' + result.segmentCount + (result.segmentCount === 1 ? ' segment' : ' segments') + ')';
document.getElementById('tc-confirm-icon').innerHTML = icon; document.getElementById('tc-confirm-icon').innerHTML = icon;
document.getElementById('tc-confirm-title').textContent = result.displayName + ' &mdash; ' + title;
document.getElementById('tc-confirm-title').innerHTML = escHtml(result.displayName) + ' &mdash; ' + title; document.getElementById('tc-confirm-title').innerHTML = escHtml(result.displayName) + ' &mdash; ' + title;
document.getElementById('tc-confirm-time').textContent = timeStr; document.getElementById('tc-confirm-time').textContent = timeStr;
document.getElementById('tc-confirm-today').textContent = todayLine; document.getElementById('tc-confirm-today').textContent = todayLine;