90f333c8f3
Fix Razor rendering of TermsVersion — property chains after a literal character need @() parentheses or Razor misparses the expression. Also adds cleanup to EnsureNotificationTemplatesSeededAsync to remove stale template rows (no longer canonical, never customised) on next settings visit, so retired types like JobReadyForPickup SMS disappear automatically. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
302 lines
16 KiB
Plaintext
302 lines
16 KiB
Plaintext
@model List<PowderCoating.Web.Controllers.CompanySmsRow>
|
|
@{
|
|
ViewData["Title"] = "SMS Agreements";
|
|
var currentVersion = ViewBag.CurrentTermsVersion as string ?? "1.0";
|
|
var filter = ViewBag.Filter as string ?? "all";
|
|
var search = ViewBag.Search as string ?? "";
|
|
}
|
|
|
|
<div class="container-fluid py-4">
|
|
|
|
<div class="d-flex align-items-center justify-content-between mb-4">
|
|
<div>
|
|
<h1 class="h3 mb-0"><i class="bi bi-file-earmark-check me-2 text-primary"></i>SMS Agreements</h1>
|
|
<p class="text-muted mb-0 small">Per-company SMS terms acceptance log — current terms version: <strong>v@currentVersion</strong></p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stats -->
|
|
<div class="row g-3 mb-4">
|
|
<div class="col-sm-4">
|
|
<div class="card border-0 shadow-sm h-100">
|
|
<div class="card-body d-flex align-items-center gap-3">
|
|
<div class="rounded-circle bg-primary bg-opacity-10 d-flex align-items-center justify-content-center" style="width:48px;height:48px;flex-shrink:0">
|
|
<i class="bi bi-building text-primary fs-5"></i>
|
|
</div>
|
|
<div>
|
|
<div class="fs-4 fw-bold lh-1">@ViewBag.TotalCompanies</div>
|
|
<div class="text-muted small">Active Companies</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-sm-4">
|
|
<div class="card border-0 shadow-sm h-100">
|
|
<div class="card-body d-flex align-items-center gap-3">
|
|
<div class="rounded-circle bg-success bg-opacity-10 d-flex align-items-center justify-content-center" style="width:48px;height:48px;flex-shrink:0">
|
|
<i class="bi bi-check-circle text-success fs-5"></i>
|
|
</div>
|
|
<div>
|
|
<div class="fs-4 fw-bold lh-1">@ViewBag.AcceptedCount</div>
|
|
<div class="text-muted small">Accepted Current Terms</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-sm-4">
|
|
<div class="card border-0 shadow-sm h-100">
|
|
<div class="card-body d-flex align-items-center gap-3">
|
|
<div class="rounded-circle bg-info bg-opacity-10 d-flex align-items-center justify-content-center" style="width:48px;height:48px;flex-shrink:0">
|
|
<i class="bi bi-chat-dots text-info fs-5"></i>
|
|
</div>
|
|
<div>
|
|
<div class="fs-4 fw-bold lh-1">@ViewBag.SmsEnabledCount</div>
|
|
<div class="text-muted small">SMS Currently Enabled</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filters + Search -->
|
|
<div class="card border-0 shadow-sm mb-4">
|
|
<div class="card-body">
|
|
<div class="d-flex flex-wrap gap-2 align-items-center justify-content-between">
|
|
<div class="d-flex flex-wrap gap-2">
|
|
<a asp-action="Index" asp-route-search="@search"
|
|
class="btn btn-sm @(filter == "all" ? "btn-primary" : "btn-outline-secondary")">All</a>
|
|
<a asp-action="Index" asp-route-filter="accepted" asp-route-search="@search"
|
|
class="btn btn-sm @(filter == "accepted" ? "btn-success" : "btn-outline-success")">
|
|
<i class="bi bi-check-circle me-1"></i>Accepted Current Terms
|
|
</a>
|
|
<a asp-action="Index" asp-route-filter="pending" asp-route-search="@search"
|
|
class="btn btn-sm @(filter == "pending" ? "btn-warning" : "btn-outline-warning")">
|
|
<i class="bi bi-clock me-1"></i>Not Accepted
|
|
</a>
|
|
<a asp-action="Index" asp-route-filter="enabled" asp-route-search="@search"
|
|
class="btn btn-sm @(filter == "enabled" ? "btn-info" : "btn-outline-info")">
|
|
<i class="bi bi-chat-dots me-1"></i>SMS Enabled
|
|
</a>
|
|
<a asp-action="Index" asp-route-filter="disabled" asp-route-search="@search"
|
|
class="btn btn-sm @(filter == "disabled" ? "btn-danger" : "btn-outline-danger")">
|
|
<i class="bi bi-slash-circle me-1"></i>Admin-Disabled
|
|
</a>
|
|
</div>
|
|
<form method="get" class="d-flex gap-2" style="min-width:240px;">
|
|
<input type="hidden" name="filter" value="@filter" />
|
|
<input type="text" name="search" value="@search" class="form-control form-control-sm"
|
|
placeholder="Search company…" />
|
|
<button type="submit" class="btn btn-sm btn-outline-secondary">
|
|
<i class="bi bi-search"></i>
|
|
</button>
|
|
@if (!string.IsNullOrWhiteSpace(search))
|
|
{
|
|
<a asp-action="Index" asp-route-filter="@filter" class="btn btn-sm btn-outline-danger">
|
|
<i class="bi bi-x"></i>
|
|
</a>
|
|
}
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Table -->
|
|
<div class="card border-0 shadow-sm">
|
|
<div class="card-body p-0">
|
|
@if (!Model.Any())
|
|
{
|
|
<div class="text-center text-muted py-5">
|
|
<i class="bi bi-file-earmark-x fs-1 d-block mb-2 opacity-25"></i>
|
|
No companies match this filter.
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="table-responsive">
|
|
<table class="table table-hover align-middle mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>Company</th>
|
|
<th>SMS Status</th>
|
|
<th>Terms Accepted</th>
|
|
<th>Accepted By</th>
|
|
<th>Accepted At</th>
|
|
<th>IP Address</th>
|
|
<th class="text-center">History</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var row in Model)
|
|
{
|
|
<tr class="@(row.IsDeleted ? "text-muted" : "")">
|
|
<td>
|
|
<div class="fw-medium">
|
|
@row.CompanyName
|
|
@if (row.IsDeleted)
|
|
{
|
|
<span class="badge bg-secondary ms-1">Deleted</span>
|
|
}
|
|
</div>
|
|
</td>
|
|
<td>
|
|
@if (row.SmsDisabledByAdmin)
|
|
{
|
|
<span class="badge bg-danger"><i class="bi bi-slash-circle me-1"></i>Admin-Disabled</span>
|
|
}
|
|
else if (row.SmsEnabled)
|
|
{
|
|
<span class="badge bg-success"><i class="bi bi-chat-dots me-1"></i>Enabled</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="badge bg-secondary">Off</span>
|
|
}
|
|
</td>
|
|
<td>
|
|
@if (row.CurrentAgreement != null)
|
|
{
|
|
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>v@(row.CurrentAgreement.TermsVersion)</span>
|
|
}
|
|
else if (row.LatestAgreement != null)
|
|
{
|
|
<span class="badge bg-warning text-dark" title="Accepted v@(row.LatestAgreement.TermsVersion) — current is v@currentVersion">
|
|
<i class="bi bi-exclamation-triangle me-1"></i>Stale (v@(row.LatestAgreement.TermsVersion))
|
|
</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="badge bg-light text-muted border">Never</span>
|
|
}
|
|
</td>
|
|
<td>
|
|
@if (row.CurrentAgreement != null)
|
|
{
|
|
<span>@row.CurrentAgreement.AgreedByUserName</span>
|
|
}
|
|
else if (row.LatestAgreement != null)
|
|
{
|
|
<span class="text-muted">@row.LatestAgreement.AgreedByUserName</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="text-muted">—</span>
|
|
}
|
|
</td>
|
|
<td>
|
|
@{
|
|
var displayAgreement = row.CurrentAgreement ?? row.LatestAgreement;
|
|
}
|
|
@if (displayAgreement != null)
|
|
{
|
|
<span class="@(row.CurrentAgreement == null ? "text-muted" : "")">
|
|
@displayAgreement.AgreedAt.ToString("MMM d, yyyy 'at' h:mm tt") UTC
|
|
</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="text-muted">—</span>
|
|
}
|
|
</td>
|
|
<td>
|
|
@if (displayAgreement?.IpAddress != null)
|
|
{
|
|
<code class="small @(row.CurrentAgreement == null ? "text-muted" : "")">@displayAgreement.IpAddress</code>
|
|
}
|
|
else
|
|
{
|
|
<span class="text-muted">—</span>
|
|
}
|
|
</td>
|
|
<td class="text-center">
|
|
@if (row.AllAgreements.Count > 0)
|
|
{
|
|
<button type="button"
|
|
class="btn btn-sm btn-outline-secondary"
|
|
data-bs-toggle="modal"
|
|
data-bs-target="#historyModal"
|
|
data-company="@row.CompanyName"
|
|
data-history="@System.Text.Json.JsonSerializer.Serialize(row.AllAgreements.Select(a => new {
|
|
a.TermsVersion,
|
|
a.AgreedByUserName,
|
|
a.AgreedByUserId,
|
|
AgreedAt = a.AgreedAt.ToString("MMM d, yyyy 'at' h:mm tt") + " UTC",
|
|
IpAddress = a.IpAddress ?? "—",
|
|
UserAgent = a.UserAgent ?? "—"
|
|
}))">
|
|
@row.AllAgreements.Count <i class="bi bi-clock-history ms-1"></i>
|
|
</button>
|
|
}
|
|
else
|
|
{
|
|
<span class="text-muted small">—</span>
|
|
}
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
}
|
|
</div>
|
|
@if (Model.Any())
|
|
{
|
|
<div class="card-footer text-muted small">
|
|
Showing @Model.Count @(Model.Count == 1 ? "company" : "companies")
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- History Modal -->
|
|
<div class="modal fade" id="historyModal" tabindex="-1" aria-labelledby="historyModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog modal-lg">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="historyModalLabel">
|
|
<i class="bi bi-clock-history me-2"></i>Agreement History — <span id="historyCompanyName"></span>
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body p-0">
|
|
<div class="table-responsive">
|
|
<table class="table table-sm align-middle mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>Terms Version</th>
|
|
<th>Accepted By</th>
|
|
<th>Accepted At</th>
|
|
<th>IP Address</th>
|
|
<th>User Agent</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="historyTableBody"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
@section Scripts {
|
|
<script>
|
|
document.getElementById('historyModal').addEventListener('show.bs.modal', function (e) {
|
|
const btn = e.relatedTarget;
|
|
document.getElementById('historyCompanyName').textContent = btn.dataset.company;
|
|
|
|
const rows = JSON.parse(btn.dataset.history);
|
|
const tbody = document.getElementById('historyTableBody');
|
|
tbody.innerHTML = rows.map(r => `
|
|
<tr>
|
|
<td><span class="badge bg-primary">v${r.termsVersion}</span></td>
|
|
<td>${r.agreedByUserName}</td>
|
|
<td>${r.agreedAt}</td>
|
|
<td><code class="small">${r.ipAddress}</code></td>
|
|
<td><small class="text-muted text-truncate d-block" style="max-width:260px;" title="${r.userAgent}">${r.userAgent}</small></td>
|
|
</tr>`).join('');
|
|
});
|
|
</script>
|
|
}
|