a0bdd2b5b4
Replace all corruption variants with HTML entities across 226 view files: - 3-char UTF-8-as-Win1252 sequences (ae-corruption) - Standalone smart/curly quotes that break C# Razor expressions - Partially re-corrupted variants where the 3rd byte was normalised to ASCII tools/Fix-Encoding.ps1: re-runnable sweep; uses [char] code points so the script itself never contains a literal non-ASCII character; supports -DryRun .githooks/pre-commit: blocks commits containing the ae-corruption byte signature (xc3xa2xe2x82xac); git core.hooksPath = .githooks so the hook is repo-committed and active for all future work on this machine. Build clean; 225 unit tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
474 lines
26 KiB
Plaintext
474 lines
26 KiB
Plaintext
@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>
|