2bf8871892
- 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>
321 lines
20 KiB
Plaintext
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>
|
|
}
|