Files
PowderCoatingLogix/src/PowderCoating.Web/Views/Customers/Index.cshtml
T
spouliot 2bf8871892 Fix NoExtraLayerCharge persistence, appointment reminders, coat notes display, scroll restoration, and invoice Send dead-button
- Appointment reminders: add AppointmentReminderBackgroundService (60s poll), ReminderSentAt
  dedup stamp, NotifyAppointmentReminderAsync sends both customer email and creator staff email;
  AppointmentReminderStaff notification type + default template added; DateTime.Now used instead
  of UtcNow to match locally-stored ScheduledStartTime; ToLocalTime() double-conversion removed

- NoExtraLayerCharge not persisted: flag existed on CreateQuoteItemCoatDto and was used by
  pricing engine but never written to JobItemCoat/QuoteItemCoat entities — every edit reset it
  to false and re-applied the extra layer charge; added column to both entities (migration
  AddNoExtraLayerChargeToCoats), both read DTOs, all 3 JobItemAssemblyService overloads,
  JobItemCoatSeed inner class, and existingItemsData JSON in all 5 wizard views; fixed JS
  template path that hard-coded noExtraLayerCharge: false

- Coat notes not visible: notes were rendered in desktop job details but missing from the wizard
  item card summary and the mobile card view; both fixed

- Scroll position lost on item save: sessionStorage save/restore added to item-wizard.js owner
  form submit handler; path-keyed so cross-page navigation does not restore stale position;
  requestAnimationFrame used for reliable mobile scroll restoration

- Invoice Send dead button: #sendChannelModal was gated inside @if (isDraft) but the button
  targeting it fires for Sent/Overdue invoices too when customer has both email and SMS; modal
  moved outside the Draft guard

- InitialCreate migration added for fresh database installs; Baseline migration guarded with
  IF OBJECT_ID check so it no-ops on fresh DBs; Razor scoping bug fixed in Customers/Index.cshtml

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:48:16 -04:00

321 lines
20 KiB
Plaintext

@model PagedResult<PowderCoating.Application.DTOs.Customer.CustomerListDto>
@{
ViewData["Title"] = "Customers";
ViewData["PageIcon"] = "bi-people";
ViewData["PageHelpTitle"] = "Customers";
ViewData["PageHelpContent"] = "Customers are companies or individuals who bring in work. Commercial customers get business features like payment terms, credit limits, and pricing tier discounts. Individual customers are typically walk-in or one-off jobs. Balance shown here is the total outstanding across all unpaid invoices.";
}
<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: "ACTIVE", Value: Model.Items.Count(c => c.IsActive).ToString(), Delta: (string?)null, DeltaDir: (string?)null))
</div>
<div class="pcl-metric-strip-cell">
@await Html.PartialAsync("_Metric", (Label: "COMMERCIAL", Value: Model.Items.Count(c => c.IsCommercial).ToString(), Delta: (string?)null, DeltaDir: (string?)null))
</div>
<div class="pcl-metric-strip-cell">
@await Html.PartialAsync("_Metric", (Label: "TOTAL BALANCE", Value: Model.Items.Sum(c => c.CurrentBalance).ToString("C0"), Delta: (string?)null, DeltaDir: (string?)null))
</div>
</div>
<!-- Customers Table Card -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-white 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 flex-sm-row gap-2 w-100 w-lg-auto">
<form method="get" class="d-flex flex-column flex-sm-row gap-2 flex-grow-1 flex-lg-grow-0">
<div class="input-group" style="max-width: 350px; min-width: 200px;">
<span class="input-group-text bg-white 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 customers..." value="@ViewBag.SearchTerm">
</div>
<button type="submit" class="btn btn-primary"><i class="bi bi-search"></i></button>
@if (!string.IsNullOrEmpty(ViewBag.SearchTerm))
{
<a asp-action="Index" class="btn btn-outline-secondary">Clear</a>
}
</form>
<a asp-action="Create" class="btn btn-primary text-nowrap">
<i class="bi bi-plus-circle me-2"></i>
<span class="d-none d-sm-inline">Add Customer</span>
<span class="d-inline d-sm-none">Add</span>
</a>
</div>
</div>
</div>
<div class="card-body p-0">
@if (!Model.Items.Any())
{
var isCustomerListFiltered = !string.IsNullOrEmpty(ViewBag.SearchTerm as string);
<div class="text-center py-5">
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
<h5 class="mt-3 text-muted">No customers found</h5>
<p class="text-muted mb-4">@(isCustomerListFiltered ? "No customers match your search." : "Get started by adding your first customer.")</p>
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>@(isCustomerListFiltered ? "Add Customer" : "Add Your First Customer")
</a>
</div>
}
else
{
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th sortable="CompanyName" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection" class="ps-4">Company</th>
<th sortable="ContactName" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Contact</th>
<th sortable="Email" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Email</th>
<th sortable="Phone" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Phone</th>
<th>Type</th>
<th sortable="CurrentBalance" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Balance</th>
<th sortable="IsActive" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Status</th>
<th>Notifications</th>
<th class="text-end pe-4">Actions</th>
</tr>
</thead>
<tbody id="customerTable">
@foreach (var customer in Model.Items)
{
<tr class="customer-row" data-customer-id="@customer.Id" style="cursor: pointer;">
<td class="ps-4">
<div class="d-flex align-items-center gap-2">
<div class="rounded-circle d-flex align-items-center justify-content-center"
style="width: 40px; height: 40px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; font-weight: 600;">
@{
var initial = "?";
if (!string.IsNullOrEmpty(customer.CompanyName) && customer.CompanyName.Length > 0)
{
initial = customer.CompanyName.Substring(0, 1).ToUpper();
}
else if (!string.IsNullOrEmpty(customer.ContactName) && customer.ContactName.Length > 0)
{
initial = customer.ContactName.Substring(0, 1).ToUpper();
}
}
@initial
</div>
<div>
<div class="fw-semibold">@(string.IsNullOrEmpty(customer.CompanyName) ? customer.ContactName ?? "Individual Customer" : customer.CompanyName)</div>
@if (customer.LastContactDate.HasValue)
{
<small class="text-muted">Last contact: @customer.LastContactDate.Value.ToString("MMM dd, yyyy")</small>
}
</div>
</div>
</td>
<td>
@if (!string.IsNullOrEmpty(customer.ContactName))
{
<span>@customer.ContactName</span>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td>
@if (!string.IsNullOrEmpty(customer.Email))
{
<a href="mailto:@customer.Email" class="text-decoration-none">
<i class="bi bi-envelope me-1"></i>@customer.Email
</a>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td>
@if (!string.IsNullOrEmpty(customer.Phone))
{
<a href="tel:@customer.Phone" class="text-decoration-none">
<i class="bi bi-telephone me-1"></i>@customer.Phone
</a>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td>
@await Html.PartialAsync("_StatusChip", (Kind: customer.IsCommercial ? "cool" : "neutral", Text: customer.IsCommercial ? "Commercial" : "Individual"))
</td>
<td>
<span class="@(customer.CurrentBalance > 0 ? "text-danger fw-semibold" : "text-success")">
@customer.CurrentBalance.ToString("C")
</span>
</td>
<td>
@await Html.PartialAsync("_StatusChip", (Kind: StatusChipHelper.Active(customer.IsActive), Text: customer.IsActive ? "Active" : "Inactive"))
</td>
<td>
<span title="@(customer.NotifyByEmail ? "Email notifications on" : "Email notifications off")">
<i class="bi @(customer.NotifyByEmail ? "bi-envelope-fill text-success" : "bi-envelope-slash text-secondary opacity-50")"></i>
</span>
<span class="ms-2" title="@(customer.NotifyBySms ? "SMS notifications on" : "SMS notifications off")">
<i class="bi @(customer.NotifyBySms ? "bi-chat-fill text-success" : "bi-chat-slash text-secondary opacity-50")"></i>
</span>
</td>
<td class="text-end pe-4">
<div class="btn-group btn-group-sm">
<a asp-action="Details" asp-route-id="@customer.Id" class="btn btn-outline-primary" title="View Details">
<i class="bi bi-eye"></i>
</a>
<a asp-action="Edit" asp-route-id="@customer.Id" class="btn btn-outline-warning" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<a asp-action="Delete" asp-route-id="@customer.Id" class="btn btn-outline-danger" title="Delete">
<i class="bi bi-trash"></i>
</a>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Mobile Card View -->
<div class="mobile-card-view">
@if (!Model.Items.Any())
{
var isMobileCustomerListFiltered = !string.IsNullOrEmpty(ViewBag.SearchTerm as string);
<div class="text-center py-5">
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
<h5 class="mt-3 text-muted">No customers found</h5>
<p class="text-muted mb-4">@(isMobileCustomerListFiltered ? "No customers match your search." : "Get started by adding your first customer.")</p>
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>@(isMobileCustomerListFiltered ? "Add Customer" : "Add Your First Customer")
</a>
</div>
}
else
{
<div class="mobile-card-list">
@foreach (var customer in Model.Items)
{
<div class="mobile-data-card"
data-id="@customer.Id"
onclick="window.location.href='@Url.Action("Details", new { id = customer.Id })'">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<i class="bi bi-@(customer.IsCommercial ? "building" : "person")"></i>
</div>
<div class="mobile-card-title">
<h6>@(string.IsNullOrEmpty(customer.CompanyName) ? customer.ContactName ?? "Individual Customer" : customer.CompanyName)</h6>
<small>@customer.Email</small>
</div>
</div>
<div class="mobile-card-body">
@if (!string.IsNullOrEmpty(customer.ContactName))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Contact</span>
<span class="mobile-card-value">@customer.ContactName</span>
</div>
}
<div class="mobile-card-row">
<span class="mobile-card-label">Phone</span>
<span class="mobile-card-value">@(customer.Phone ?? "—")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Type</span>
<span class="mobile-card-value">
@if (customer.IsCommercial)
{
<span class="badge bg-primary bg-opacity-10 text-primary">
<i class="bi bi-building me-1"></i>Commercial
</span>
}
else
{
<span class="badge bg-secondary bg-opacity-10 text-secondary">
<i class="bi bi-person me-1"></i>Individual
</span>
}
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Balance</span>
<span class="mobile-card-value @(customer.CurrentBalance > 0 ? "text-danger fw-semibold" : "text-success")">
@customer.CurrentBalance.ToString("C")
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value">
@if (customer.IsActive)
{
<span class="badge bg-success bg-opacity-10 text-success">
<i class="bi bi-check-circle me-1"></i>Active
</span>
}
else
{
<span class="badge bg-danger bg-opacity-10 text-danger">
<i class="bi bi-x-circle me-1"></i>Inactive
</span>
}
</span>
</div>
@if (customer.LastContactDate.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Last Contact</span>
<span class="mobile-card-value">@customer.LastContactDate.Value.ToString("MMM dd, yyyy")</span>
</div>
}
</div>
<div class="mobile-card-footer">
<a href="@Url.Action("Details", new { id = customer.Id })"
class="btn btn-sm btn-outline-primary"
onclick="event.stopPropagation();">
<i class="bi bi-eye me-1"></i>View
</a>
<a href="@Url.Action("Edit", new { id = customer.Id })"
class="btn btn-sm btn-outline-secondary"
onclick="event.stopPropagation();">
<i class="bi bi-pencil me-1"></i>Edit
</a>
</div>
</div>
}
</div>
}
</div>
}
</div>
@if (Model.TotalCount > 0)
{
@await Html.PartialAsync("_Pagination", Model)
}
</div>
@section Scripts {
<script>
// Make table rows clickable
document.querySelectorAll('.customer-row').forEach(row => {
row.addEventListener('click', function(e) {
// Don't navigate if clicking on action buttons or links
if (e.target.closest('.btn-group') || e.target.closest('a') || e.target.closest('button')) {
return;
}
const customerId = this.getAttribute('data-customer-id');
window.location.href = '@Url.Action("Details", "Customers")/' + customerId;
});
// Hover handled by CSS .table tbody tr:hover
});
</script>
}