Restore all zeroed views + add bulk gift certificate creation
The HTML entity sweep script had a bug where it wrote empty files for any
view that contained no target Unicode characters, zeroing out 215 view files.
All views restored from the pre-sweep commit (cefdf3e).
Bulk gift certificate feature:
- BulkCreateGiftCertificateDto with Quantity (1-500), Amount, Reason, Expiry, Notes
- GenerateBulkGiftCertificatePdfAsync on IPdfService / PdfService: one Letter page
per cert, reusing the same purple/gold branded ComposeGiftCertificateContent helper
- GiftCertificatesController: BulkCreate GET/POST, BulkResult GET, BulkDownloadPdf POST
- Views: BulkCreate.cshtml (form with live total preview), BulkResult.cshtml (table +
Download All PDF button that POSTs cert IDs to avoid URL length limits)
- gift-certificate-bulk.js: live preview + spinner/disable on submit
- Index.cshtml: Bulk Create button added alongside New Certificate
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,473 @@
|
||||
@using PowderCoating.Application.DTOs.Job
|
||||
@using PowderCoating.Application.DTOs.Quote
|
||||
@using PowderCoating.Application.DTOs.Common
|
||||
|
||||
@{
|
||||
ViewData["Title"] = $"Activity - {ViewBag.CustomerName}";
|
||||
ViewData["PageIcon"] = "bi-clock-history";
|
||||
|
||||
var customerId = (int)ViewBag.CustomerId;
|
||||
var customerName = (string)ViewBag.CustomerName;
|
||||
var activeTab = (string)ViewBag.ActiveTab;
|
||||
|
||||
var jobs = (PagedResult<JobListDto>)ViewBag.Jobs;
|
||||
var jobSort = (string)ViewBag.JobSort;
|
||||
var jobDir = (string)ViewBag.JobDir;
|
||||
|
||||
var quotes = (PagedResult<QuoteListDto>)ViewBag.Quotes;
|
||||
var quoteSort = (string)ViewBag.QuoteSort;
|
||||
var quoteDir = (string)ViewBag.QuoteDir;
|
||||
|
||||
// Helper: build a URL that changes only the job sort column/direction
|
||||
string JobSortUrl(string col)
|
||||
{
|
||||
var newDir = jobSort == col && jobDir == "asc" ? "desc" : "asc";
|
||||
return Url.Action("Activity", new
|
||||
{
|
||||
id = customerId,
|
||||
activeTab = "jobs",
|
||||
jobSort = col,
|
||||
jobDir = newDir,
|
||||
jobPage = 1,
|
||||
jobSize = jobs.PageSize,
|
||||
quoteSort,
|
||||
quoteDir,
|
||||
quotePage = quotes.PageNumber,
|
||||
quoteSize = quotes.PageSize
|
||||
})!;
|
||||
}
|
||||
|
||||
string JobSortIcon(string col)
|
||||
{
|
||||
if (jobSort != col) return "bi-arrow-down-up";
|
||||
return jobDir == "asc" ? "bi-arrow-up" : "bi-arrow-down";
|
||||
}
|
||||
|
||||
// Helper: build a URL that changes only the quote sort column/direction
|
||||
string QuoteSortUrl(string col)
|
||||
{
|
||||
var newDir = quoteSort == col && quoteDir == "asc" ? "desc" : "asc";
|
||||
return Url.Action("Activity", new
|
||||
{
|
||||
id = customerId,
|
||||
activeTab = "quotes",
|
||||
jobSort,
|
||||
jobDir,
|
||||
jobPage = jobs.PageNumber,
|
||||
jobSize = jobs.PageSize,
|
||||
quoteSort = col,
|
||||
quoteDir = newDir,
|
||||
quotePage = 1,
|
||||
quoteSize = quotes.PageSize
|
||||
})!;
|
||||
}
|
||||
|
||||
string QuoteSortIcon(string col)
|
||||
{
|
||||
if (quoteSort != col) return "bi-arrow-down-up";
|
||||
return quoteDir == "asc" ? "bi-arrow-up" : "bi-arrow-down";
|
||||
}
|
||||
|
||||
// Helper: build page-change URL for jobs
|
||||
string JobPageUrl(int page)
|
||||
{
|
||||
return Url.Action("Activity", new
|
||||
{
|
||||
id = customerId,
|
||||
activeTab = "jobs",
|
||||
jobSort,
|
||||
jobDir,
|
||||
jobPage = page,
|
||||
jobSize = jobs.PageSize,
|
||||
quoteSort,
|
||||
quoteDir,
|
||||
quotePage = quotes.PageNumber,
|
||||
quoteSize = quotes.PageSize
|
||||
})!;
|
||||
}
|
||||
|
||||
// Helper: build page-change URL for quotes
|
||||
string QuotePageUrl(int page)
|
||||
{
|
||||
return Url.Action("Activity", new
|
||||
{
|
||||
id = customerId,
|
||||
activeTab = "quotes",
|
||||
jobSort,
|
||||
jobDir,
|
||||
jobPage = jobs.PageNumber,
|
||||
jobSize = jobs.PageSize,
|
||||
quoteSort,
|
||||
quoteDir,
|
||||
quotePage = page,
|
||||
quoteSize = quotes.PageSize
|
||||
})!;
|
||||
}
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-end gap-2 mb-4">
|
||||
<a asp-controller="Jobs" asp-action="Create" asp-route-customerId="@customerId" class="btn btn-success">
|
||||
<i class="bi bi-plus-circle me-2"></i>New Job
|
||||
</a>
|
||||
<a asp-controller="Quotes" asp-action="Create" asp-route-customerId="@customerId" class="btn btn-outline-primary">
|
||||
<i class="bi bi-file-text me-2"></i>New Quote
|
||||
</a>
|
||||
<a asp-action="Details" asp-route-id="@customerId" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-2"></i>Back to Customer
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Summary Badges -->
|
||||
<div class="d-flex gap-3 mb-4">
|
||||
<span class="badge bg-primary bg-opacity-10 text-primary fs-6 px-3 py-2">
|
||||
<i class="bi bi-briefcase me-2"></i>@jobs.TotalCount Job@(jobs.TotalCount == 1 ? "" : "s")
|
||||
</span>
|
||||
<span class="badge bg-info bg-opacity-10 text-info fs-6 px-3 py-2">
|
||||
<i class="bi bi-file-text me-2"></i>@quotes.TotalCount Quote@(quotes.TotalCount == 1 ? "" : "s")
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<ul class="nav nav-tabs mb-0" id="activityTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link @(activeTab == "jobs" ? "active" : "")" id="jobs-tab"
|
||||
data-bs-toggle="tab" data-bs-target="#jobs-panel"
|
||||
type="button" role="tab">
|
||||
<i class="bi bi-briefcase me-2"></i>Jobs
|
||||
<span class="badge bg-secondary ms-1">@jobs.TotalCount</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link @(activeTab == "quotes" ? "active" : "")" id="quotes-tab"
|
||||
data-bs-toggle="tab" data-bs-target="#quotes-panel"
|
||||
type="button" role="tab">
|
||||
<i class="bi bi-file-text me-2"></i>Quotes
|
||||
<span class="badge bg-secondary ms-1">@quotes.TotalCount</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content" id="activityTabContent">
|
||||
|
||||
<!-- ===== JOBS TAB ===== -->
|
||||
<div class="tab-pane fade @(activeTab == "jobs" ? "show active" : "")" id="jobs-panel" role="tabpanel">
|
||||
<div class="card border-0 shadow-sm border-top-0" style="border-top-left-radius:0; border-top-right-radius:0;">
|
||||
<div class="card-body p-0">
|
||||
@if (!jobs.Items.Any())
|
||||
{
|
||||
<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 jobs found</h5>
|
||||
<p class="text-muted mb-4">This customer has no jobs yet</p>
|
||||
<a asp-controller="Jobs" asp-action="Create" asp-route-customerId="@customerId" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-2"></i>Create First Job
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="ps-4">
|
||||
<a href="@JobSortUrl("JobNumber")" class="text-decoration-none" style="color:inherit">
|
||||
Job Number <i class="bi @JobSortIcon("JobNumber")"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th>Description</th>
|
||||
<th>
|
||||
<a href="@JobSortUrl("Status")" class="text-decoration-none" style="color:inherit">
|
||||
Status <i class="bi @JobSortIcon("Status")"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a href="@JobSortUrl("Priority")" class="text-decoration-none" style="color:inherit">
|
||||
Priority <i class="bi @JobSortIcon("Priority")"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a href="@JobSortUrl("DueDate")" class="text-decoration-none" style="color:inherit">
|
||||
Due Date <i class="bi @JobSortIcon("DueDate")"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a href="@JobSortUrl("FinalPrice")" class="text-decoration-none" style="color:inherit">
|
||||
Price <i class="bi @JobSortIcon("FinalPrice")"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a href="@JobSortUrl("CreatedAt")" class="text-decoration-none" style="color:inherit">
|
||||
Created <i class="bi @JobSortIcon("CreatedAt")"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th class="text-end pe-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var job in jobs.Items)
|
||||
{
|
||||
<tr style="cursor:pointer;" onclick="window.location.href='@Url.Action("Details", "Jobs", new { id = job.Id })'">
|
||||
<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:38px;height:38px;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);color:white;">
|
||||
<i class="bi bi-briefcase"></i>
|
||||
</div>
|
||||
<span class="fw-semibold">@job.JobNumber</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-truncate" style="max-width:220px;">@job.Description</td>
|
||||
<td>
|
||||
<span class="badge bg-@job.StatusColorClass bg-opacity-10 text-@job.StatusColorClass">
|
||||
@job.StatusDisplayName
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-@job.PriorityColorClass">@job.PriorityDisplayName</span>
|
||||
</td>
|
||||
<td>
|
||||
@if (job.DueDate.HasValue)
|
||||
{
|
||||
var overdue = job.DueDate.Value < DateTime.Now
|
||||
&& job.StatusCode != "COMPLETED"
|
||||
&& job.StatusCode != "DELIVERED";
|
||||
<span class="@(overdue ? "text-danger fw-semibold" : "")">
|
||||
@job.DueDate.Value.ToString("MMM dd, yyyy")
|
||||
@if (overdue) { <i class="bi bi-exclamation-triangle ms-1"></i> }
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="fw-semibold">@job.FinalPrice.ToString("C")</td>
|
||||
<td class="text-muted small">@job.CreatedAt.ToString("MMM dd, yyyy")</td>
|
||||
<td class="text-end pe-4" onclick="event.stopPropagation()">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@job.Id"
|
||||
class="btn btn-outline-primary" title="View">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<a asp-controller="Jobs" asp-action="Edit" asp-route-id="@job.Id"
|
||||
class="btn btn-outline-warning" title="Edit">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Jobs Pagination -->
|
||||
@if (jobs.TotalPages > 1)
|
||||
{
|
||||
<div class="card-footer bg-transparent d-flex justify-content-between align-items-center py-3 px-4">
|
||||
<div class="text-muted small">
|
||||
Showing @jobs.StartIndex–@jobs.EndIndex of @jobs.TotalCount jobs
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<span class="text-muted small me-1">Rows:</span>
|
||||
@foreach (var sz in new[] { 10, 25, 50 })
|
||||
{
|
||||
var szUrl = Url.Action("Activity", new
|
||||
{
|
||||
id = customerId, activeTab = "jobs",
|
||||
jobSort, jobDir, jobPage = 1, jobSize = sz,
|
||||
quoteSort, quoteDir, quotePage = quotes.PageNumber, quoteSize = quotes.PageSize
|
||||
});
|
||||
<a href="@szUrl" class="btn btn-sm @(jobs.PageSize == sz ? "btn-primary" : "btn-outline-secondary")">@sz</a>
|
||||
}
|
||||
</div>
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
<li class="page-item @(jobs.PageNumber == 1 ? "disabled" : "")">
|
||||
<a class="page-link" href="@JobPageUrl(jobs.PageNumber - 1)">
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</a>
|
||||
</li>
|
||||
@for (var p = Math.Max(1, jobs.PageNumber - 2); p <= Math.Min(jobs.TotalPages, jobs.PageNumber + 2); p++)
|
||||
{
|
||||
<li class="page-item @(p == jobs.PageNumber ? "active" : "")">
|
||||
<a class="page-link" href="@JobPageUrl(p)">@p</a>
|
||||
</li>
|
||||
}
|
||||
<li class="page-item @(jobs.PageNumber == jobs.TotalPages ? "disabled" : "")">
|
||||
<a class="page-link" href="@JobPageUrl(jobs.PageNumber + 1)">
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== QUOTES TAB ===== -->
|
||||
<div class="tab-pane fade @(activeTab == "quotes" ? "show active" : "")" id="quotes-panel" role="tabpanel">
|
||||
<div class="card border-0 shadow-sm border-top-0" style="border-top-left-radius:0; border-top-right-radius:0;">
|
||||
<div class="card-body p-0">
|
||||
@if (!quotes.Items.Any())
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-file-text" style="font-size: 4rem; color: #d1d5db;"></i>
|
||||
<h5 class="mt-3 text-muted">No quotes found</h5>
|
||||
<p class="text-muted mb-4">This customer has no quotes yet</p>
|
||||
<a asp-controller="Quotes" asp-action="Create" asp-route-customerId="@customerId" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-2"></i>Create First Quote
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="ps-4">
|
||||
<a href="@QuoteSortUrl("QuoteNumber")" class="text-decoration-none" style="color:inherit">
|
||||
Quote # <i class="bi @QuoteSortIcon("QuoteNumber")"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a href="@QuoteSortUrl("Status")" class="text-decoration-none" style="color:inherit">
|
||||
Status <i class="bi @QuoteSortIcon("Status")"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a href="@QuoteSortUrl("QuoteDate")" class="text-decoration-none" style="color:inherit">
|
||||
Quote Date <i class="bi @QuoteSortIcon("QuoteDate")"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a href="@QuoteSortUrl("Expiration")" class="text-decoration-none" style="color:inherit">
|
||||
Expires <i class="bi @QuoteSortIcon("Expiration")"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a href="@QuoteSortUrl("Total")" class="text-decoration-none" style="color:inherit">
|
||||
Total <i class="bi @QuoteSortIcon("Total")"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th class="text-end pe-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var quote in quotes.Items)
|
||||
{
|
||||
<tr style="cursor:pointer;" onclick="window.location.href='@Url.Action("Details", "Quotes", new { id = quote.Id })'">
|
||||
<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:38px;height:38px;background:linear-gradient(135deg,#11998e 0%,#38ef7d 100%);color:white;">
|
||||
<i class="bi bi-file-text"></i>
|
||||
</div>
|
||||
<span class="fw-semibold">@quote.QuoteNumber</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@{
|
||||
var isExpired = quote.IsExpired;
|
||||
}
|
||||
@if (isExpired)
|
||||
{
|
||||
<span class="badge bg-danger bg-opacity-10 text-danger">Expired</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-@quote.StatusColorClass bg-opacity-10 text-@quote.StatusColorClass">
|
||||
@quote.StatusDisplayName
|
||||
</span>
|
||||
}
|
||||
</td>
|
||||
<td>@quote.QuoteDate.ToString("MMM dd, yyyy")</td>
|
||||
<td>
|
||||
@if (quote.ExpirationDate.HasValue)
|
||||
{
|
||||
<span class="@(isExpired ? "text-danger fw-semibold" : "")">
|
||||
@quote.ExpirationDate.Value.ToString("MMM dd, yyyy")
|
||||
@if (isExpired) { <i class="bi bi-exclamation-triangle ms-1"></i> }
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="fw-semibold">@quote.Total.ToString("C")</td>
|
||||
<td class="text-end pe-4" onclick="event.stopPropagation()">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a asp-controller="Quotes" asp-action="Details" asp-route-id="@quote.Id"
|
||||
class="btn btn-outline-primary" title="View">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<a asp-controller="Quotes" asp-action="Edit" asp-route-id="@quote.Id"
|
||||
class="btn btn-outline-warning" title="Edit">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Quotes Pagination -->
|
||||
@if (quotes.TotalPages > 1)
|
||||
{
|
||||
<div class="card-footer bg-transparent d-flex justify-content-between align-items-center py-3 px-4">
|
||||
<div class="text-muted small">
|
||||
Showing @quotes.StartIndex–@quotes.EndIndex of @quotes.TotalCount quotes
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<span class="text-muted small me-1">Rows:</span>
|
||||
@foreach (var sz in new[] { 10, 25, 50 })
|
||||
{
|
||||
var szUrl = Url.Action("Activity", new
|
||||
{
|
||||
id = customerId, activeTab = "quotes",
|
||||
jobSort, jobDir, jobPage = jobs.PageNumber, jobSize = jobs.PageSize,
|
||||
quoteSort, quoteDir, quotePage = 1, quoteSize = sz
|
||||
});
|
||||
<a href="@szUrl" class="btn btn-sm @(quotes.PageSize == sz ? "btn-primary" : "btn-outline-secondary")">@sz</a>
|
||||
}
|
||||
</div>
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
<li class="page-item @(quotes.PageNumber == 1 ? "disabled" : "")">
|
||||
<a class="page-link" href="@QuotePageUrl(quotes.PageNumber - 1)">
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</a>
|
||||
</li>
|
||||
@for (var p = Math.Max(1, quotes.PageNumber - 2); p <= Math.Min(quotes.TotalPages, quotes.PageNumber + 2); p++)
|
||||
{
|
||||
<li class="page-item @(p == quotes.PageNumber ? "active" : "")">
|
||||
<a class="page-link" href="@QuotePageUrl(p)">@p</a>
|
||||
</li>
|
||||
}
|
||||
<li class="page-item @(quotes.PageNumber == quotes.TotalPages ? "disabled" : "")">
|
||||
<a class="page-link" href="@QuotePageUrl(quotes.PageNumber + 1)">
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
@model PowderCoating.Application.DTOs.Customer.CreateCustomerDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Add New Customer";
|
||||
ViewData["PageIcon"] = "bi-person-plus";
|
||||
}
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-10">
|
||||
<div class="d-flex justify-content-end align-items-center mb-4">
|
||||
<a asp-action="Index" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-2"></i>Back to List
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<form asp-action="Create" method="post">
|
||||
<partial name="_ValidationSummary" />
|
||||
|
||||
<!-- Company Information Section -->
|
||||
<div class="mb-4">
|
||||
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
|
||||
<h5 class="mb-0"><i class="bi bi-building me-2 text-primary"></i>Company Information</h5>
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Company Information"
|
||||
data-bs-content="Company Name is used on quotes, invoices, and correspondence. Leave it blank for individual (non-business) customers. Customer Type controls which features are available — Commercial customers get payment terms, credit limits, and pricing tier discounts; Individual customers are for simpler one-off work.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<label asp-for="CompanyName" class="form-label">Company Name</label>
|
||||
<input asp-for="CompanyName" class="form-control" placeholder="Enter company name" />
|
||||
<span asp-validation-for="CompanyName" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label asp-for="IsCommercial" class="form-label">Customer Type
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Customer Type"
|
||||
data-bs-content="Commercial: businesses with ongoing work, purchase orders, and invoicing. Individual: walk-in customers or one-off jobs. This affects which fields are shown and whether pricing tier discounts apply.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</label>
|
||||
<select asp-for="IsCommercial" class="form-select">
|
||||
<option value="false">Individual</option>
|
||||
<option value="true">Commercial</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Information Section -->
|
||||
<div class="mb-4">
|
||||
<h5 class="border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-person me-2 text-primary"></i>Contact Information
|
||||
</h5>
|
||||
<div class="alert alert-info alert-permanent py-2 px-3 mb-3" style="font-size:.875rem;">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
<strong>Required:</strong> At least one of Company Name, First Name, or Last Name — and at least one of Email, Phone, or Mobile Phone.
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label asp-for="ContactFirstName" class="form-label">First Name <span class="text-muted fw-normal">(required if no company name)</span></label>
|
||||
<input asp-for="ContactFirstName" class="form-control" placeholder="Enter first name" />
|
||||
<span asp-validation-for="ContactFirstName" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label asp-for="ContactLastName" class="form-label">Last Name</label>
|
||||
<input asp-for="ContactLastName" class="form-control" placeholder="Enter last name" />
|
||||
<span asp-validation-for="ContactLastName" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label asp-for="Email" class="form-label">Email <span class="text-danger">*</span> <span class="text-muted fw-normal">(required if no phone number)</span></label>
|
||||
<input asp-for="Email" type="email" multiple class="form-control" placeholder="name@example.com (comma-separate multiple)" />
|
||||
<span asp-validation-for="Email" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label asp-for="Phone" class="form-label">Phone <span class="text-danger">*</span> <span class="text-muted fw-normal">(required if no email)</span></label>
|
||||
<input asp-for="Phone" type="tel" class="form-control" placeholder="(555) 123-4567" />
|
||||
<span asp-validation-for="Phone" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label asp-for="MobilePhone" class="form-label">Mobile Phone</label>
|
||||
<input asp-for="MobilePhone" type="tel" class="form-control" placeholder="(555) 123-4567" />
|
||||
<span asp-validation-for="MobilePhone" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-6" id="billingEmailRow" style="display:none;">
|
||||
<label asp-for="BillingEmail" class="form-label">Billing / Accounting Email
|
||||
<span class="text-muted fw-normal">(invoices sent here)</span>
|
||||
</label>
|
||||
<input asp-for="BillingEmail" type="email" multiple class="form-control" placeholder="accounting@company.com (comma-separate multiple)" />
|
||||
<span asp-validation-for="BillingEmail" class="text-danger"></span>
|
||||
<div class="form-text">When set, invoices are emailed here instead of the contact email.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Address Section -->
|
||||
<div class="mb-4">
|
||||
<h5 class="border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-geo-alt me-2 text-primary"></i>Address
|
||||
</h5>
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label asp-for="Address" class="form-label">Street Address</label>
|
||||
<input asp-for="Address" class="form-control" placeholder="Enter street address" />
|
||||
<span asp-validation-for="Address" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label asp-for="City" class="form-label">City</label>
|
||||
<input asp-for="City" class="form-control" placeholder="Enter city" />
|
||||
<span asp-validation-for="City" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label asp-for="State" class="form-label">State</label>
|
||||
<input asp-for="State" class="form-control" placeholder="Enter state" />
|
||||
<span asp-validation-for="State" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label asp-for="ZipCode" class="form-label">Zip Code</label>
|
||||
<input asp-for="ZipCode" class="form-control" placeholder="12345" />
|
||||
<span asp-validation-for="ZipCode" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label asp-for="Country" class="form-label">Country</label>
|
||||
<select asp-for="Country" class="form-select">
|
||||
<option value="">-- Select --</option>
|
||||
<option value="USA">USA</option>
|
||||
<option value="Canada">Canada</option>
|
||||
<option value="Mexico">Mexico</option>
|
||||
<option value="Afghanistan">Afghanistan</option>
|
||||
<option value="Albania">Albania</option>
|
||||
<option value="Algeria">Algeria</option>
|
||||
<option value="Argentina">Argentina</option>
|
||||
<option value="Australia">Australia</option>
|
||||
<option value="Austria">Austria</option>
|
||||
<option value="Bangladesh">Bangladesh</option>
|
||||
<option value="Belgium">Belgium</option>
|
||||
<option value="Bolivia">Bolivia</option>
|
||||
<option value="Brazil">Brazil</option>
|
||||
<option value="Chile">Chile</option>
|
||||
<option value="China">China</option>
|
||||
<option value="Colombia">Colombia</option>
|
||||
<option value="Costa Rica">Costa Rica</option>
|
||||
<option value="Croatia">Croatia</option>
|
||||
<option value="Czech Republic">Czech Republic</option>
|
||||
<option value="Denmark">Denmark</option>
|
||||
<option value="Dominican Republic">Dominican Republic</option>
|
||||
<option value="Ecuador">Ecuador</option>
|
||||
<option value="Egypt">Egypt</option>
|
||||
<option value="El Salvador">El Salvador</option>
|
||||
<option value="Finland">Finland</option>
|
||||
<option value="France">France</option>
|
||||
<option value="Germany">Germany</option>
|
||||
<option value="Ghana">Ghana</option>
|
||||
<option value="Greece">Greece</option>
|
||||
<option value="Guatemala">Guatemala</option>
|
||||
<option value="Honduras">Honduras</option>
|
||||
<option value="Hungary">Hungary</option>
|
||||
<option value="India">India</option>
|
||||
<option value="Indonesia">Indonesia</option>
|
||||
<option value="Iran">Iran</option>
|
||||
<option value="Iraq">Iraq</option>
|
||||
<option value="Ireland">Ireland</option>
|
||||
<option value="Israel">Israel</option>
|
||||
<option value="Italy">Italy</option>
|
||||
<option value="Japan">Japan</option>
|
||||
<option value="Jordan">Jordan</option>
|
||||
<option value="Kazakhstan">Kazakhstan</option>
|
||||
<option value="Kenya">Kenya</option>
|
||||
<option value="South Korea">South Korea</option>
|
||||
<option value="Kuwait">Kuwait</option>
|
||||
<option value="Malaysia">Malaysia</option>
|
||||
<option value="Netherlands">Netherlands</option>
|
||||
<option value="New Zealand">New Zealand</option>
|
||||
<option value="Nicaragua">Nicaragua</option>
|
||||
<option value="Nigeria">Nigeria</option>
|
||||
<option value="Norway">Norway</option>
|
||||
<option value="Pakistan">Pakistan</option>
|
||||
<option value="Panama">Panama</option>
|
||||
<option value="Paraguay">Paraguay</option>
|
||||
<option value="Peru">Peru</option>
|
||||
<option value="Philippines">Philippines</option>
|
||||
<option value="Poland">Poland</option>
|
||||
<option value="Portugal">Portugal</option>
|
||||
<option value="Puerto Rico">Puerto Rico</option>
|
||||
<option value="Romania">Romania</option>
|
||||
<option value="Russia">Russia</option>
|
||||
<option value="Saudi Arabia">Saudi Arabia</option>
|
||||
<option value="South Africa">South Africa</option>
|
||||
<option value="Spain">Spain</option>
|
||||
<option value="Sweden">Sweden</option>
|
||||
<option value="Switzerland">Switzerland</option>
|
||||
<option value="Taiwan">Taiwan</option>
|
||||
<option value="Thailand">Thailand</option>
|
||||
<option value="Turkey">Turkey</option>
|
||||
<option value="Ukraine">Ukraine</option>
|
||||
<option value="United Arab Emirates">United Arab Emirates</option>
|
||||
<option value="United Kingdom">United Kingdom</option>
|
||||
<option value="Uruguay">Uruguay</option>
|
||||
<option value="Venezuela">Venezuela</option>
|
||||
<option value="Vietnam">Vietnam</option>
|
||||
</select>
|
||||
<span asp-validation-for="Country" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Business Information Section -->
|
||||
<div class="mb-4">
|
||||
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
|
||||
<h5 class="mb-0"><i class="bi bi-briefcase me-2 text-primary"></i>Business Information</h5>
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Business Information"
|
||||
data-bs-content="These fields govern billing and compliance. Payment Terms sets the default for invoices (e.g., Net 30 = payment due within 30 days). Credit Limit is a soft cap on outstanding balance — the system will warn when exceeded. Tax Exempt removes tax from all invoices for this customer (upload the exemption certificate on the Edit page).">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label asp-for="TaxId" class="form-label">Tax ID / EIN</label>
|
||||
<input asp-for="TaxId" class="form-control" placeholder="Enter tax ID" />
|
||||
<span asp-validation-for="TaxId" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<label asp-for="PaymentTerms" class="form-label 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="Sets the default due date on invoices for this customer. 'Net 30' means payment is due 30 days after the invoice date. This is a default — you can override it on individual invoices.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<select asp-for="PaymentTerms" class="form-select">
|
||||
<option value="">Select payment terms</option>
|
||||
<option value="Net 15">Net 15</option>
|
||||
<option value="Net 30">Net 30</option>
|
||||
<option value="Net 45">Net 45</option>
|
||||
<option value="Net 60">Net 60</option>
|
||||
<option value="Due on Receipt">Due on Receipt</option>
|
||||
<option value="Cash on Delivery">Cash on Delivery</option>
|
||||
</select>
|
||||
<span asp-validation-for="PaymentTerms" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label asp-for="PricingTierId" class="form-label">Pricing Tier</label>
|
||||
<select asp-for="PricingTierId" asp-items="ViewBag.PricingTiers" class="form-select">
|
||||
<option value="">— No tier —</option>
|
||||
</select>
|
||||
<small class="text-muted">Applies a discount to all quotes for this customer.</small>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<label asp-for="CreditLimit" class="form-label mb-0">Credit Limit</label>
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Credit Limit"
|
||||
data-bs-content="The maximum outstanding balance you'll extend to this customer before requiring payment. Set to $0 to allow unlimited credit or to require payment upfront. The system will warn (but not block) when the balance exceeds this limit.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input asp-for="CreditLimit" type="number" step="0.01" min="0" value="0" class="form-control" placeholder="0.00" />
|
||||
</div>
|
||||
<span asp-validation-for="CreditLimit" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-md-6">
|
||||
<div class="form-check form-switch">
|
||||
<input asp-for="IsTaxExempt" class="form-check-input" type="checkbox" />
|
||||
<label asp-for="IsTaxExempt" class="form-check-label">Tax Exempt Customer</label>
|
||||
</div>
|
||||
<small class="text-muted">Check this box if the customer is tax exempt</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes Section -->
|
||||
<div class="mb-4">
|
||||
<h5 class="border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-journal-text me-2 text-primary"></i>Notes
|
||||
</h5>
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label asp-for="GeneralNotes" class="form-label">General Notes</label>
|
||||
<textarea asp-for="GeneralNotes" class="form-control" rows="4" placeholder="Enter any additional notes about this customer"></textarea>
|
||||
<span asp-validation-for="GeneralNotes" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification Preferences -->
|
||||
<div class="mb-4">
|
||||
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
|
||||
<h5 class="mb-0"><i class="bi bi-bell me-2 text-primary"></i>Notification Preferences</h5>
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Notification Preferences"
|
||||
data-bs-content="Controls when the customer receives automatic updates. Email notifications send status change alerts (e.g., job ready for pickup) to the customer's email address. SMS requires separate TCPA consent and a mobile phone number.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<!-- Email -->
|
||||
<div class="col-md-6">
|
||||
<div class="form-check form-switch">
|
||||
<input asp-for="NotifyByEmail" class="form-check-input" type="checkbox" role="switch" />
|
||||
<label asp-for="NotifyByEmail" class="form-check-label">
|
||||
<i class="bi bi-envelope me-1"></i>Email Notifications
|
||||
</label>
|
||||
<div class="form-text">Receive quote and job status updates by email.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (ViewBag.SmsEnabled == true)
|
||||
{
|
||||
<!-- SMS Consent (TCPA compliance) -->
|
||||
<div class="mt-3">
|
||||
<div class="card border-warning">
|
||||
<div class="card-header bg-warning-subtle text-warning-emphasis d-flex align-items-center gap-2 py-2">
|
||||
<i class="bi bi-shield-exclamation fs-5"></i>
|
||||
<span class="fw-semibold">SMS Consent Requirement (TCPA)</span>
|
||||
<span class="badge bg-warning text-dark ms-auto">Required before enabling SMS</span>
|
||||
</div>
|
||||
<div class="card-body pb-2">
|
||||
<p class="mb-2">
|
||||
Federal law (TCPA) requires <strong>explicit prior written or verbal consent</strong> before sending SMS messages to a customer.
|
||||
Before enabling SMS notifications, you must:
|
||||
</p>
|
||||
<ol class="mb-2 ps-3">
|
||||
<li>Inform the customer they will receive automated text messages for job updates and pickup alerts.</li>
|
||||
<li>Inform them that message and data rates may apply.</li>
|
||||
<li>Explain they can reply <strong>STOP</strong> at any time to opt out.</li>
|
||||
<li>Obtain their clear verbal or written agreement.</li>
|
||||
</ol>
|
||||
<p class="mb-2 small text-muted">
|
||||
Only check the box below <strong>after</strong> the customer has given consent.
|
||||
A confirmation text will be sent automatically to verify enrollment.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-secondary bg-body-secondary p-3">
|
||||
<div class="form-check">
|
||||
<input asp-for="SmsConsentGranted" class="form-check-input" type="checkbox" id="SmsConsentGranted" />
|
||||
<label class="form-check-label fw-semibold" for="SmsConsentGranted">
|
||||
<i class="bi bi-shield-check me-1 text-success"></i>
|
||||
Customer has verbally consented to receive SMS notifications
|
||||
</label>
|
||||
<div class="form-text">
|
||||
Checking this box records consent on behalf of the customer and triggers a confirmation text.
|
||||
A mobile phone number must be entered above.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="d-flex gap-2 justify-content-end pt-3 border-top">
|
||||
<a asp-action="Index" class="btn btn-outline-secondary px-4">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary px-4">
|
||||
<i class="bi bi-check-circle me-2"></i>Create Customer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
<script src="~/js/customer-billing-email.js"></script>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
@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())
|
||||
{
|
||||
<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">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>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())
|
||||
{
|
||||
<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">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>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>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
@model PowderCoating.Application.DTOs.Common.PagedResult<PowderCoating.Application.DTOs.Invoice.InvoiceListDto>
|
||||
@using PowderCoating.Core.Enums
|
||||
@using PowderCoating.Web.Controllers
|
||||
|
||||
@{
|
||||
ViewData["Title"] = $"Invoices - {ViewBag.CustomerName}";
|
||||
ViewData["PageIcon"] = "bi-receipt";
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-end gap-2 mb-4">
|
||||
<a asp-controller="Invoices" asp-action="Create" asp-route-customerId="@ViewBag.CustomerId" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-2"></i>New Invoice
|
||||
</a>
|
||||
<a asp-action="Details" asp-route-id="@ViewBag.CustomerId" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-2"></i>Back to Customer
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-0 py-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0 fw-semibold">
|
||||
@Model.TotalCount invoice(s) total
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
@if (!Model.Items.Any())
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-receipt" style="font-size: 4rem; color: #d1d5db;"></i>
|
||||
<h5 class="mt-3 text-muted">No invoices found</h5>
|
||||
<p class="text-muted mb-4">This customer has no invoices yet</p>
|
||||
<a asp-controller="Invoices" asp-action="Create" asp-route-customerId="@ViewBag.CustomerId" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-2"></i>Create Invoice
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th sortable="InvoiceNumber" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection" class="ps-4">Invoice #</th>
|
||||
<th>Job #</th>
|
||||
<th sortable="Status" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Status</th>
|
||||
<th sortable="DueDate" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Due Date</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 class="text-end pe-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var inv in Model.Items)
|
||||
{
|
||||
<tr style="cursor: pointer;" onclick="window.location.href='@Url.Action("Details", "Invoices", new { id = inv.Id })'">
|
||||
<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, #f093fb 0%, #f5576c 100%); color: white; font-weight: 600;">
|
||||
<i class="bi bi-receipt"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-semibold">@inv.InvoiceNumber</div>
|
||||
<small class="text-muted">@inv.InvoiceDate.Tz(ViewBag.CompanyTimeZone as string).ToString("MMM dd, yyyy")</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
@if (inv.JobId.HasValue && !string.IsNullOrEmpty(inv.JobNumber))
|
||||
{
|
||||
<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="text-muted fst-italic">No job</span>
|
||||
}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<span class="badge bg-@InvoicesController.GetStatusColorClass(inv.Status)">
|
||||
@InvoicesController.GetStatusDisplay(inv.Status)
|
||||
</span>
|
||||
@if (inv.IsOverdue)
|
||||
{
|
||||
<span class="badge bg-danger ms-1">Overdue</span>
|
||||
}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
@if (inv.DueDate.HasValue)
|
||||
{
|
||||
<span class="@(inv.IsOverdue ? "text-danger fw-semibold" : "")">
|
||||
@inv.DueDate.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MMM dd, yyyy")
|
||||
@if (inv.IsOverdue)
|
||||
{
|
||||
<i class="bi bi-exclamation-triangle ms-1"></i>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="align-middle text-end fw-semibold">@inv.Total.ToString("C")</td>
|
||||
<td class="align-middle text-end">
|
||||
@if (inv.BalanceDue > 0)
|
||||
{
|
||||
<span class="fw-semibold text-danger">@inv.BalanceDue.ToString("C")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-success fw-semibold">Paid</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-end pe-4 align-middle" onclick="event.stopPropagation()">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a asp-controller="Invoices" asp-action="Details" asp-route-id="@inv.Id"
|
||||
class="btn btn-outline-primary" title="View Invoice">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<a asp-controller="Invoices" asp-action="DownloadPdf" asp-route-id="@inv.Id"
|
||||
class="btn btn-outline-secondary" title="Download PDF">
|
||||
<i class="bi bi-file-earmark-pdf"></i>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (Model.TotalCount > 0)
|
||||
{
|
||||
@await Html.PartialAsync("_Pagination", Model)
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
@model PowderCoating.Application.DTOs.Accounting.CustomerStatementDto
|
||||
@{
|
||||
ViewData["Title"] = $"Statement – {Model.CustomerName}";
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-start mb-4 flex-wrap gap-2">
|
||||
<div>
|
||||
<h4 class="mb-0">Customer Statement</h4>
|
||||
<p class="text-muted mb-0">@Model.CustomerName · @Model.From.ToString("MMM d, yyyy") – @Model.To.ToString("MMM d, yyyy")</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<form method="get" class="d-flex gap-2 align-items-center">
|
||||
<input type="hidden" name="id" value="@(ViewContext.RouteData.Values["id"])" />
|
||||
<input type="date" name="from" class="form-control form-control-sm" value="@Model.From.ToString("yyyy-MM-dd")" />
|
||||
<input type="date" name="to" class="form-control form-control-sm" value="@Model.To.ToString("yyyy-MM-dd")" />
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">Refresh</button>
|
||||
</form>
|
||||
<a asp-action="Statement" asp-route-id="@(ViewContext.RouteData.Values["id"])"
|
||||
asp-route-from="@Model.From.ToString("yyyy-MM-dd")"
|
||||
asp-route-to="@Model.To.ToString("yyyy-MM-dd")"
|
||||
asp-route-pdf="true"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-file-earmark-pdf me-1"></i>Download PDF
|
||||
</a>
|
||||
<a asp-action="Details" asp-route-id="@(ViewContext.RouteData.Values["id"])" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header border-0 py-3 bg-white d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="fw-semibold">@Model.CustomerName</span>
|
||||
@if (!string.IsNullOrWhiteSpace(Model.CustomerAddress))
|
||||
{
|
||||
<span class="text-muted small ms-2">@Model.CustomerAddress</span>
|
||||
}
|
||||
</div>
|
||||
<div class="text-muted small">@Model.CompanyName</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th style="width:100px">Date</th>
|
||||
<th style="width:120px">Type</th>
|
||||
<th style="width:130px">Reference</th>
|
||||
<th>Description</th>
|
||||
<th class="text-end" style="width:110px">Debit</th>
|
||||
<th class="text-end" style="width:110px">Credit</th>
|
||||
<th class="text-end" style="width:120px">Balance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Opening balance -->
|
||||
<tr class="table-light fw-semibold">
|
||||
<td class="text-muted">@Model.From.AddDays(-1).ToString("MM/dd/yy")</td>
|
||||
<td colspan="5">Opening Balance</td>
|
||||
<td class="text-end">@Model.OpeningBalance.ToString("C")</td>
|
||||
</tr>
|
||||
|
||||
@if (!Model.Lines.Any())
|
||||
{
|
||||
<tr>
|
||||
<td colspan="7" class="text-center text-muted py-4">No activity in this period.</td>
|
||||
</tr>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var line in Model.Lines)
|
||||
{
|
||||
<tr>
|
||||
<td class="text-muted small">@line.Date.ToString("MM/dd/yy")</td>
|
||||
<td>
|
||||
<span class="badge @(line.Type == "Invoice" ? "bg-primary" : line.Type == "Payment" ? "bg-success" : "bg-secondary") text-white">
|
||||
@line.Type
|
||||
</span>
|
||||
</td>
|
||||
<td class="small">@line.Reference</td>
|
||||
<td class="small text-muted">@line.Description</td>
|
||||
<td class="text-end small">@(line.Debit.HasValue ? line.Debit.Value.ToString("C") : "")</td>
|
||||
<td class="text-end small">@(line.Credit.HasValue ? line.Credit.Value.ToString("C") : "")</td>
|
||||
<td class="text-end small @(line.RunningBalance > 0 ? "text-danger" : "text-success") fw-semibold">
|
||||
@line.RunningBalance.ToString("C")
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Closing balance -->
|
||||
<tr class="table-secondary fw-bold">
|
||||
<td colspan="6">Closing Balance</td>
|
||||
<td class="text-end @(Model.ClosingBalance > 0 ? "text-danger" : "text-success")">
|
||||
@Model.ClosingBalance.ToString("C")
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user