Replace literal Unicode special chars with HTML entities across all 233 views
Sweeps em dashes, en dashes, multiplication signs, ellipses, and curly quotes to their HTML entity equivalents (— – × … ‘ ’) in all .cshtml files, skipping <script> blocks. Prevents encoding corruption from AI tools and Windows encoding mismatches that caused recurring symbol bugs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,393 +0,0 @@
|
||||
@using PowderCoating.Core.Entities
|
||||
@using PowderCoating.Core.Enums
|
||||
@model List<Company>
|
||||
|
||||
@section Styles {
|
||||
<style>
|
||||
tr.cursor-pointer { cursor: pointer; }
|
||||
[data-bs-theme="dark"] .card-header.bg-white { background-color: var(--bs-card-cap-bg) !important; }
|
||||
</style>
|
||||
}
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Subscription Management";
|
||||
int page = ViewBag.Page;
|
||||
int totalPages = ViewBag.TotalPages;
|
||||
int totalCount = ViewBag.TotalCount;
|
||||
int pageSize = ViewBag.PageSize;
|
||||
var planConfigs = (dynamic)ViewBag.PlanConfigs;
|
||||
|
||||
string PlanName(int plan)
|
||||
{
|
||||
foreach (var p in planConfigs)
|
||||
if (p.Plan == plan) return p.DisplayName;
|
||||
return plan.ToString();
|
||||
}
|
||||
|
||||
string StatusBadge(SubscriptionStatus s) => s switch {
|
||||
SubscriptionStatus.Active => "bg-success",
|
||||
SubscriptionStatus.GracePeriod => "bg-warning",
|
||||
SubscriptionStatus.Expired => "bg-danger",
|
||||
SubscriptionStatus.Canceled => "bg-secondary",
|
||||
SubscriptionStatus.Inactive => "bg-secondary",
|
||||
_ => "bg-secondary"
|
||||
};
|
||||
|
||||
string SortLink(string col) {
|
||||
var dir = ViewBag.SortCol == col && ViewBag.SortDir == "asc" ? "desc" : "asc";
|
||||
return Url.Action("Index", new { search = ViewBag.Search, status = ViewBag.StatusFilter,
|
||||
plan = ViewBag.PlanFilter, sortCol = col, sortDir = dir, pageSize })!;
|
||||
}
|
||||
string PageLink(int p) => Url.Action("Index", new { search = ViewBag.Search, status = ViewBag.StatusFilter,
|
||||
plan = ViewBag.PlanFilter, sortCol = ViewBag.SortCol, sortDir = ViewBag.SortDir, page = p, pageSize })!;
|
||||
}
|
||||
|
||||
<div class="container-fluid py-3">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<h4 class="mb-0"><i class="bi bi-credit-card me-2 text-primary"></i>Subscription Management</h4>
|
||||
<small class="text-muted">@totalCount.ToString("N0") companies</small>
|
||||
</div>
|
||||
<a href="@Url.Action("ExportCsv", new { search = ViewBag.Search, status = ViewBag.StatusFilter, plan = ViewBag.PlanFilter })"
|
||||
class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-download me-1"></i>Export CSV
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@* Bulk Actions Toolbar (hidden until rows selected) *@
|
||||
<div id="bulk-toolbar" class="alert alert-primary d-none d-flex align-items-center gap-3 py-2 mb-3">
|
||||
<span class="fw-semibold small"><span id="selected-count">0</span> selected</span>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<form method="post" asp-action="BulkExtendTrial" class="d-flex gap-1 align-items-center" id="extend-form">
|
||||
@Html.AntiForgeryToken()
|
||||
<select name="days" class="form-select form-select-sm" style="width:auto">
|
||||
<option value="7">+7 days</option>
|
||||
<option value="14">+14 days</option>
|
||||
<option value="30">+30 days</option>
|
||||
<option value="90">+90 days</option>
|
||||
</select>
|
||||
<button type="submit" class="btn btn-sm btn-primary">Extend Trial</button>
|
||||
</form>
|
||||
<form method="post" asp-action="BulkToggleActive" asp-route-active="true" id="activate-form">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="active" value="true" />
|
||||
<button type="submit" class="btn btn-sm btn-success">Activate</button>
|
||||
</form>
|
||||
<form method="post" asp-action="BulkToggleActive" asp-route-active="false" id="deactivate-form">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="active" value="false" />
|
||||
<button type="submit" class="btn btn-sm btn-danger">Deactivate</button>
|
||||
</form>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-secondary ms-auto" onclick="clearSelection()">Clear</button>
|
||||
</div>
|
||||
|
||||
@* Plan Feature Flags *@
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-header border-0 py-2 d-flex align-items-center gap-2"
|
||||
style="cursor:pointer;" data-bs-toggle="collapse" data-bs-target="#planFeaturesPanel">
|
||||
<i class="bi bi-toggles me-1 text-secondary"></i>
|
||||
<span class="fw-semibold small">Plan Feature Flags</span>
|
||||
<i class="bi bi-chevron-down ms-auto small text-muted"></i>
|
||||
</div>
|
||||
<div class="collapse" id="planFeaturesPanel">
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm align-middle mb-0 small">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Plan</th>
|
||||
<th class="text-center">Online Payments (Stripe Connect)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var p in planConfigs)
|
||||
{
|
||||
<tr>
|
||||
<td class="fw-medium ps-3">@p.DisplayName</td>
|
||||
<td class="text-center">
|
||||
<div class="form-check form-switch d-inline-block mb-0">
|
||||
<input class="form-check-input plan-feature-toggle"
|
||||
type="checkbox" role="switch"
|
||||
data-plan-id="@p.Id"
|
||||
data-feature="AllowOnlinePayments"
|
||||
@(p.AllowOnlinePayments ? "checked" : "")
|
||||
title="Toggle online payment capability for @p.DisplayName plan" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="px-3 py-2 text-muted" style="font-size:0.75rem;">
|
||||
Changes take effect immediately. Companies on a plan with Online Payments enabled can connect their Stripe account in Company Settings.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Filters *@
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-body py-2">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<input name="search" value="@ViewBag.Search" class="form-control form-control-sm"
|
||||
placeholder="Company name, email, Stripe ID…" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="status" class="form-select form-select-sm">
|
||||
<option value="">All statuses</option>
|
||||
@foreach (var s in Enum.GetValues<SubscriptionStatus>())
|
||||
{
|
||||
<option value="@s" selected="@(ViewBag.StatusFilter == s.ToString())">@s</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="plan" class="form-select form-select-sm">
|
||||
<option value="">All plans</option>
|
||||
@foreach (var p in planConfigs)
|
||||
{
|
||||
<option value="@p.Plan" selected="@(ViewBag.PlanFilter == p.Plan.ToString())">@p.DisplayName</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-sm btn-primary">Filter</button>
|
||||
<a asp-action="Index" class="btn btn-sm btn-outline-secondary ms-1">Clear</a>
|
||||
</div>
|
||||
<input type="hidden" name="pageSize" value="@pageSize" />
|
||||
<input type="hidden" name="sortCol" value="@ViewBag.SortCol" />
|
||||
<input type="hidden" name="sortDir" value="@ViewBag.SortDir" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0 small">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:36px"><input type="checkbox" id="select-all" title="Select all" /></th>
|
||||
<th><a href="@SortLink("CompanyName")" class="text-decoration-none text-body">Company</a></th>
|
||||
<th><a href="@SortLink("Plan")" class="text-decoration-none text-body">Plan</a></th>
|
||||
<th><a href="@SortLink("Status")" class="text-decoration-none text-body">Status</a></th>
|
||||
<th><a href="@SortLink("Active")" class="text-decoration-none text-body">Active</a></th>
|
||||
<th><a href="@SortLink("EndDate")" class="text-decoration-none text-body">End Date</a></th>
|
||||
<th>Stripe Customer</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (!Model.Any())
|
||||
{
|
||||
<tr><td colspan="8" class="text-center text-muted py-5">No companies found.</td></tr>
|
||||
}
|
||||
@foreach (var c in Model)
|
||||
{
|
||||
var isExpired = c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate < DateTime.UtcNow;
|
||||
var manageUrl = Url.Action("Manage", new { id = c.Id });
|
||||
<tr class="@(isExpired ? "table-danger" : "") cursor-pointer"
|
||||
onclick="rowClick(event, @c.Id, '@manageUrl')">
|
||||
<td onclick="event.stopPropagation()">
|
||||
<input type="checkbox" class="row-check" value="@c.Id" />
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-medium">@c.CompanyName</div>
|
||||
<small class="text-muted">@c.PrimaryContactEmail</small>
|
||||
</td>
|
||||
<td>@PlanName(c.SubscriptionPlan)</td>
|
||||
<td><span class="badge @StatusBadge(c.SubscriptionStatus)">@c.SubscriptionStatus</span></td>
|
||||
<td>
|
||||
@if (c.IsActive) { <i class="bi bi-check-circle-fill text-success"></i> }
|
||||
else { <i class="bi bi-x-circle-fill text-danger"></i> }
|
||||
</td>
|
||||
<td>
|
||||
@if (c.SubscriptionEndDate.HasValue)
|
||||
{
|
||||
<span class="@(isExpired ? "text-danger fw-semibold" : "")">
|
||||
@c.SubscriptionEndDate.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd/yyyy")
|
||||
</span>
|
||||
}
|
||||
else { <span class="text-muted">—</span> }
|
||||
</td>
|
||||
<td>
|
||||
@if (!string.IsNullOrEmpty(c.StripeCustomerId))
|
||||
{ <code class="small">@c.StripeCustomerId</code> }
|
||||
else { <span class="text-muted">—</span> }
|
||||
</td>
|
||||
<td onclick="event.stopPropagation()">
|
||||
<a asp-action="Manage" asp-route-id="@c.Id"
|
||||
class="btn btn-sm btn-outline-primary py-0 px-2">
|
||||
<i class="bi bi-pencil me-1"></i>Manage
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile card view — shown on screens < 992px (table-responsive hidden by mobile-cards.css) -->
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@if (!Model.Any())
|
||||
{
|
||||
<div class="text-center text-muted py-5">No companies found.</div>
|
||||
}
|
||||
@foreach (var c in Model)
|
||||
{
|
||||
var isExpiredMobile = c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate < DateTime.UtcNow;
|
||||
<a href="@Url.Action("Manage", new { id = c.Id })" class="mobile-data-card text-decoration-none">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon bg-primary"><i class="bi bi-building"></i></div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@c.CompanyName</h6>
|
||||
<small class="text-muted">@PlanName(c.SubscriptionPlan)</small>
|
||||
</div>
|
||||
<span class="badge @StatusBadge(c.SubscriptionStatus)">@c.SubscriptionStatus</span>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Active</span>
|
||||
<span class="mobile-card-value">
|
||||
@if (c.IsActive) { <i class="bi bi-check-circle-fill text-success"></i> }
|
||||
else { <i class="bi bi-x-circle-fill text-danger"></i> }
|
||||
</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">End Date</span>
|
||||
<span class="mobile-card-value @(isExpiredMobile ? "text-danger fw-semibold" : "")">
|
||||
@if (c.SubscriptionEndDate.HasValue)
|
||||
{
|
||||
@c.SubscriptionEndDate.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd/yyyy")
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Email</span>
|
||||
<span class="mobile-card-value">@c.PrimaryContactEmail</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<span class="btn btn-sm btn-outline-primary">Manage →</span>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (totalPages > 1)
|
||||
{
|
||||
<div class="card-footer d-flex align-items-center justify-content-between py-2">
|
||||
<small class="text-muted">
|
||||
Showing @((page - 1) * pageSize + 1)–@Math.Min(page * pageSize, totalCount) of @totalCount.ToString("N0")
|
||||
</small>
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
<li class="page-item @(page == 1 ? "disabled" : "")">
|
||||
<a class="page-link" href="@PageLink(page - 1)"><i class="bi bi-chevron-left"></i></a>
|
||||
</li>
|
||||
@for (int p = Math.Max(1, page - 2); p <= Math.Min(totalPages, page + 2); p++)
|
||||
{
|
||||
<li class="page-item @(p == page ? "active" : "")">
|
||||
<a class="page-link" href="@PageLink(p)">@p</a>
|
||||
</li>
|
||||
}
|
||||
<li class="page-item @(page == totalPages ? "disabled" : "")">
|
||||
<a class="page-link" href="@PageLink(page + 1)"><i class="bi bi-chevron-right"></i></a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<select class="form-select form-select-sm" style="width:auto"
|
||||
onchange="window.location='@PageLink(1)'.replace('pageSize=@pageSize','pageSize='+this.value)">
|
||||
@foreach (var ps in new[] { 10, 25, 50, 100 })
|
||||
{
|
||||
<option value="@ps" selected="@(pageSize == ps)">@ps / page</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@Html.AntiForgeryToken()
|
||||
<script>
|
||||
(function () {
|
||||
// ── Plan feature toggles ──────────────────────────────────────────
|
||||
document.querySelectorAll('.plan-feature-toggle').forEach(function (toggle) {
|
||||
toggle.addEventListener('change', async function () {
|
||||
const planId = this.dataset.planId, feature = this.dataset.feature;
|
||||
const enabled = this.checked, original = !enabled;
|
||||
try {
|
||||
const resp = await fetch('/SubscriptionManagement/TogglePlanFeature', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]').value
|
||||
},
|
||||
body: `planId=${planId}&feature=${encodeURIComponent(feature)}&enabled=${enabled}`
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!data.success) { this.checked = original; alert('Failed to update plan feature.'); }
|
||||
} catch { this.checked = original; alert('Network error updating plan feature.'); }
|
||||
});
|
||||
});
|
||||
|
||||
// ── Bulk selection ────────────────────────────────────────────────
|
||||
const toolbar = document.getElementById('bulk-toolbar');
|
||||
const countEl = document.getElementById('selected-count');
|
||||
const selectAll = document.getElementById('select-all');
|
||||
|
||||
function getSelected() {
|
||||
return Array.from(document.querySelectorAll('.row-check:checked')).map(c => c.value);
|
||||
}
|
||||
|
||||
function updateToolbar() {
|
||||
const ids = getSelected();
|
||||
countEl.textContent = ids.length;
|
||||
toolbar.classList.toggle('d-none', ids.length === 0);
|
||||
toolbar.classList.toggle('d-flex', ids.length > 0);
|
||||
// Inject hidden id inputs into each bulk form
|
||||
['extend-form', 'activate-form', 'deactivate-form'].forEach(formId => {
|
||||
const form = document.getElementById(formId);
|
||||
form.querySelectorAll('input[name="ids"]').forEach(i => i.remove());
|
||||
ids.forEach(id => {
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'hidden'; inp.name = 'ids'; inp.value = id;
|
||||
form.appendChild(inp);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.row-check').forEach(cb =>
|
||||
cb.addEventListener('change', updateToolbar));
|
||||
|
||||
selectAll.addEventListener('change', function () {
|
||||
document.querySelectorAll('.row-check').forEach(cb => cb.checked = this.checked);
|
||||
updateToolbar();
|
||||
});
|
||||
|
||||
window.clearSelection = function () {
|
||||
document.querySelectorAll('.row-check').forEach(cb => cb.checked = false);
|
||||
selectAll.checked = false;
|
||||
updateToolbar();
|
||||
};
|
||||
|
||||
window.rowClick = function (e, id, url) {
|
||||
if (e.target.type === 'checkbox' || e.target.closest('a,button,form')) return;
|
||||
window.location = url;
|
||||
};
|
||||
|
||||
// Confirm bulk destructive actions
|
||||
['activate-form', 'deactivate-form'].forEach(formId => {
|
||||
document.getElementById(formId).addEventListener('submit', function (e) {
|
||||
const count = getSelected().length;
|
||||
if (!confirm(`This will affect ${count} company(ies). Continue?`)) e.preventDefault();
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -1,553 +0,0 @@
|
||||
@using PowderCoating.Core.Entities
|
||||
@using PowderCoating.Core.Enums
|
||||
@model Company
|
||||
@{
|
||||
ViewData["Title"] = $"Manage – {Model.CompanyName}";
|
||||
var planConfigs = (dynamic)ViewBag.PlanConfigs;
|
||||
|
||||
string PlanName(int plan)
|
||||
{
|
||||
foreach (var p in planConfigs) if (p.Plan == plan) return p.DisplayName;
|
||||
return plan.ToString();
|
||||
}
|
||||
|
||||
PowderCoating.Core.Entities.SubscriptionPlanConfig? currentPlanConfig = null;
|
||||
foreach (var p in planConfigs)
|
||||
{
|
||||
if (p.Plan == Model.SubscriptionPlan) { currentPlanConfig = p; break; }
|
||||
}
|
||||
|
||||
string PlanLimit(int value) => value == -1 ? "Unlimited" : value == 0 ? "None" : value.ToString();
|
||||
}
|
||||
|
||||
<div class="container-fluid py-3" style="max-width:900px">
|
||||
<div class="d-flex align-items-center gap-3 mb-3">
|
||||
<a asp-action="Index" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back
|
||||
</a>
|
||||
<a asp-controller="Companies" asp-action="Edit" asp-route-id="@Model.Id"
|
||||
class="btn btn-outline-secondary">
|
||||
<i class="bi bi-building me-1"></i>Edit Company
|
||||
</a>
|
||||
<h4 class="mb-0">
|
||||
<i class="bi bi-credit-card me-2 text-primary"></i>@Model.CompanyName
|
||||
</h4>
|
||||
@if (Model.IsComped)
|
||||
{
|
||||
<span class="badge bg-success"><i class="bi bi-star-fill me-1"></i>Comped</span>
|
||||
}
|
||||
@if (!Model.IsActive)
|
||||
{
|
||||
<span class="badge bg-danger">Inactive</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (TempData["Success"] != null)
|
||||
{
|
||||
<div class="alert alert-success alert-dismissible mb-3" role="alert">
|
||||
@TempData["Success"]
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="row g-3">
|
||||
@* Usage stats *@
|
||||
<div class="col-md-4">
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-header bg-white border-0 py-3">
|
||||
<h6 class="mb-0 fw-semibold">Usage</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row small mb-0">
|
||||
<dt class="col-7 text-muted">Jobs</dt>
|
||||
<dd class="col-5 fw-semibold">@ViewBag.JobCount</dd>
|
||||
<dt class="col-7 text-muted">Customers</dt>
|
||||
<dd class="col-5 fw-semibold">@ViewBag.CustomerCount</dd>
|
||||
<dt class="col-7 text-muted">Users</dt>
|
||||
<dd class="col-5 fw-semibold">@ViewBag.UserCount</dd>
|
||||
<dt class="col-7 text-muted">Stripe Customer</dt>
|
||||
<dd class="col-5"><code class="small">@(Model.StripeCustomerId ?? "—")</code></dd>
|
||||
<dt class="col-7 text-muted">Stripe Sub</dt>
|
||||
<dd class="col-5"><code class="small">@(Model.StripeSubscriptionId ?? "—")</code></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Quick extend buttons *@
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-0 py-3">
|
||||
<h6 class="mb-0 fw-semibold">Quick Extend</h6>
|
||||
</div>
|
||||
<div class="card-body d-flex flex-wrap gap-2">
|
||||
@foreach (var days in new[] { 7, 14, 30, 90 })
|
||||
{
|
||||
<form method="post" asp-action="ExtendTrial" asp-route-id="@Model.Id" class="d-inline">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="days" value="@days" />
|
||||
<button class="btn btn-sm btn-outline-primary">+@days days</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
<div class="card-footer small text-muted py-2">
|
||||
Extends from current end date (or today if expired). Sets status to Active.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Edit form *@
|
||||
<div class="col-md-8">
|
||||
<form method="post" asp-action="UpdateSubscription" asp-route-id="@Model.Id">
|
||||
@Html.AntiForgeryToken()
|
||||
|
||||
@* Comped / Internal card — prominent *@
|
||||
<div class="card border-0 shadow-sm mb-3 @(Model.IsComped ? "border-success border-2" : "")">
|
||||
<div class="card-header border-0 py-3 @(Model.IsComped ? "bg-success bg-opacity-10" : "bg-white")">
|
||||
<h6 class="mb-0 fw-semibold">
|
||||
<i class="bi bi-star me-2 text-success"></i>Comped / Internal Access
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-check form-switch mb-2">
|
||||
<input class="form-check-input" type="checkbox" role="switch"
|
||||
name="isComped" value="true" id="isComped"
|
||||
@(Model.IsComped ? "checked" : "") />
|
||||
<label class="form-check-label fw-medium" for="isComped">
|
||||
Mark as Comped (complimentary/internal)
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-muted small mb-0">
|
||||
When enabled: all plan limits become unlimited, expiry banners and lockouts are suppressed,
|
||||
and the subscription end date is ignored. Use for internal/demo tenants.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Subscription settings *@
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-header bg-white border-0 py-3">
|
||||
<h6 class="mb-0 fw-semibold">Subscription Settings</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Plan</label>
|
||||
<select name="subscriptionPlan" class="form-select">
|
||||
@foreach (var p in planConfigs)
|
||||
{
|
||||
<option value="@p.Plan" selected="@(Model.SubscriptionPlan == p.Plan)">@p.DisplayName</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Status</label>
|
||||
<select name="subscriptionStatus" class="form-select">
|
||||
@foreach (var s in Enum.GetValues<SubscriptionStatus>())
|
||||
{
|
||||
<option value="@s" selected="@(Model.SubscriptionStatus == s)">@s</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">End Date</label>
|
||||
<input type="date" name="subscriptionEndDate" class="form-control"
|
||||
value="@(Model.SubscriptionEndDate?.Tz(ViewBag.CompanyTimeZone as string).ToString("yyyy-MM-dd"))" />
|
||||
<div class="form-text">Leave blank for no expiry.</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Account Active</label>
|
||||
<div class="form-check form-switch mt-2">
|
||||
<input class="form-check-input" type="checkbox" name="isActive" value="true"
|
||||
id="isActive" @(Model.IsActive ? "checked" : "") />
|
||||
<label class="form-check-label" for="isActive">
|
||||
Company can log in and use the app
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-medium">Internal Notes <span class="text-muted fw-normal small">(not shown to company)</span></label>
|
||||
<textarea name="subscriptionNotes" class="form-control" rows="2"
|
||||
placeholder="e.g. 'Comped per sales agreement', 'Trial extended by request'">@Model.SubscriptionNotes</textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-medium">Change Reason <span class="text-muted fw-normal small">(logged to audit trail)</span></label>
|
||||
<textarea name="notes" class="form-control" rows="1"
|
||||
placeholder="Brief reason for this manual change"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Per-company limit overrides *@
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-header bg-white border-0 py-3">
|
||||
<h6 class="mb-0 fw-semibold">
|
||||
<i class="bi bi-sliders me-2 text-secondary"></i>Plan Limit Overrides
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-3">
|
||||
Leave blank to use the plan default. Enter <strong>-1</strong> for unlimited.
|
||||
@if (currentPlanConfig != null)
|
||||
{
|
||||
<span>Current plan: <strong>@currentPlanConfig.DisplayName</strong></span>
|
||||
}
|
||||
</p>
|
||||
<div class="row g-3">
|
||||
<div class="col-sm-6 col-lg-4">
|
||||
<label class="form-label small fw-medium">Max Users</label>
|
||||
<input type="number" name="maxUsersOverride" class="form-control form-control-sm"
|
||||
value="@Model.MaxUsersOverride" placeholder="Plan default" />
|
||||
@if (currentPlanConfig != null)
|
||||
{
|
||||
<div class="form-text">Plan default: <strong>@PlanLimit(currentPlanConfig.MaxUsers)</strong></div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-4">
|
||||
<label class="form-label small fw-medium">Max Active Jobs</label>
|
||||
<input type="number" name="maxActiveJobsOverride" class="form-control form-control-sm"
|
||||
value="@Model.MaxActiveJobsOverride" placeholder="Plan default" />
|
||||
@if (currentPlanConfig != null)
|
||||
{
|
||||
<div class="form-text">Plan default: <strong>@PlanLimit(currentPlanConfig.MaxActiveJobs)</strong></div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-4">
|
||||
<label class="form-label small fw-medium">Max Customers</label>
|
||||
<input type="number" name="maxCustomersOverride" class="form-control form-control-sm"
|
||||
value="@Model.MaxCustomersOverride" placeholder="Plan default" />
|
||||
@if (currentPlanConfig != null)
|
||||
{
|
||||
<div class="form-text">Plan default: <strong>@PlanLimit(currentPlanConfig.MaxCustomers)</strong></div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-4">
|
||||
<label class="form-label small fw-medium">Max Quotes / Month</label>
|
||||
<input type="number" name="maxQuotesOverride" class="form-control form-control-sm"
|
||||
value="@Model.MaxQuotesOverride" placeholder="Plan default" />
|
||||
@if (currentPlanConfig != null)
|
||||
{
|
||||
<div class="form-text">Plan default: <strong>@PlanLimit(currentPlanConfig.MaxQuotes)</strong></div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-4">
|
||||
<label class="form-label small fw-medium">Max Catalog Items</label>
|
||||
<input type="number" name="maxCatalogItemsOverride" class="form-control form-control-sm"
|
||||
value="@Model.MaxCatalogItemsOverride" placeholder="Plan default" />
|
||||
@if (currentPlanConfig != null)
|
||||
{
|
||||
<div class="form-text">Plan default: <strong>@PlanLimit(currentPlanConfig.MaxCatalogItems)</strong></div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-4">
|
||||
<label class="form-label small fw-medium">Max Photos / Job</label>
|
||||
<input type="number" name="maxJobPhotosOverride" class="form-control form-control-sm"
|
||||
value="@Model.MaxJobPhotosOverride" placeholder="Plan default" />
|
||||
@if (currentPlanConfig != null)
|
||||
{
|
||||
<div class="form-text">Plan default: <strong>@PlanLimit(currentPlanConfig.MaxJobPhotos)</strong></div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-4">
|
||||
<label class="form-label small fw-medium">Max Photos / Quote</label>
|
||||
<input type="number" name="maxQuotePhotosOverride" class="form-control form-control-sm"
|
||||
value="@Model.MaxQuotePhotosOverride" placeholder="Plan default" />
|
||||
@if (currentPlanConfig != null)
|
||||
{
|
||||
<div class="form-text">Plan default: <strong>@PlanLimit(currentPlanConfig.MaxQuotePhotos)</strong></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg me-1"></i>Save Changes
|
||||
</button>
|
||||
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Feature Flags *@
|
||||
<div class="card border-0 shadow-sm mt-3">
|
||||
<div class="card-header bg-white border-0 py-3">
|
||||
<h6 class="mb-0 fw-semibold"><i class="bi bi-toggles me-2 text-secondary"></i>Feature Flags</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" asp-action="UpdateFeatureFlags" asp-route-id="@Model.Id">
|
||||
@Html.AntiForgeryToken()
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch"
|
||||
name="aiPhotoQuotesEnabled" value="true" id="aiPhotoQuotes"
|
||||
@(Model.AiPhotoQuotesEnabled ? "checked" : "") />
|
||||
<label class="form-check-label" for="aiPhotoQuotes">AI Photo Quotes</label>
|
||||
</div>
|
||||
<div class="form-text">Allow this company to use AI photo quoting.</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch"
|
||||
name="aiInventoryAssistEnabled" value="true" id="aiInventory"
|
||||
@(Model.AiInventoryAssistEnabled ? "checked" : "") />
|
||||
<label class="form-check-label" for="aiInventory">AI Inventory Assist</label>
|
||||
</div>
|
||||
<div class="form-text">Allow AI-powered inventory lookups.</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch"
|
||||
name="aiCatalogPriceCheckEnabled" value="true" id="aiCatalogPriceCheck"
|
||||
@(Model.AiCatalogPriceCheckEnabled ? "checked" : "") />
|
||||
<label class="form-check-label" for="aiCatalogPriceCheck">AI Catalog Price Check</label>
|
||||
</div>
|
||||
<div class="form-text">Override: grants access regardless of plan tier.</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small fw-medium">AI Photo Quotes / Month Override</label>
|
||||
<input type="number" name="maxAiPhotoQuotesPerMonthOverride" class="form-control form-control-sm"
|
||||
value="@Model.MaxAiPhotoQuotesPerMonthOverride" placeholder="Plan default" />
|
||||
<div class="form-text">0 = disabled, -1 = unlimited, blank = plan default.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-sm btn-primary">Save Feature Flags</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Stripe Payment History + Refunds *@
|
||||
@if (!string.IsNullOrEmpty(Model.StripeCustomerId))
|
||||
{
|
||||
<div class="card border-0 shadow-sm mt-3">
|
||||
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0 fw-semibold"><i class="bi bi-stripe me-2 text-primary"></i>Stripe Payment History</h6>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="loadPaymentHistory()">Load</button>
|
||||
</div>
|
||||
<div class="card-body p-0" id="payment-history-container">
|
||||
<p class="text-muted small p-3 mb-0">Click Load to fetch payment history from Stripe.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Refund Modal -->
|
||||
<div class="modal fade" id="refundModal" tabindex="-1" aria-labelledby="refundModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="refundModalLabel">
|
||||
<i class="bi bi-arrow-counterclockwise me-2 text-danger"></i>Issue Subscription Refund
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warning alert-permanent small mb-3">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
This will immediately issue a refund via Stripe. This action cannot be undone.
|
||||
</div>
|
||||
<dl class="row small mb-3">
|
||||
<dt class="col-5 text-muted">Invoice</dt>
|
||||
<dd class="col-7 font-monospace" id="refund-invoice-number">—</dd>
|
||||
<dt class="col-5 text-muted">Amount Paid</dt>
|
||||
<dd class="col-7 fw-semibold" id="refund-amount-paid">—</dd>
|
||||
<dt class="col-5 text-muted">Max Refundable</dt>
|
||||
<dd class="col-7 fw-semibold text-success" id="refund-max-amount">—</dd>
|
||||
</dl>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-medium">Refund Amount</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input type="number" id="refund-amount-input" class="form-control"
|
||||
step="0.01" min="0.01" placeholder="0.00" />
|
||||
</div>
|
||||
<div class="form-text">Enter a partial amount or leave the default for a full refund.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-medium">Reason <span class="text-muted fw-normal">(logged to audit trail)</span></label>
|
||||
<textarea id="refund-reason-input" class="form-control" rows="2"
|
||||
placeholder="e.g. Customer requested cancellation refund, duplicate charge, etc."></textarea>
|
||||
</div>
|
||||
<div id="refund-result" class="d-none"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" id="refund-submit-btn" onclick="submitRefund()">
|
||||
<i class="bi bi-arrow-counterclockwise me-1"></i>Issue Refund
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Recent audit entries for this company *@
|
||||
<div class="card border-0 shadow-sm mt-3">
|
||||
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0 fw-semibold">Recent Audit Activity</h6>
|
||||
<a asp-controller="AuditLog" asp-action="Index" asp-route-companyId="@Model.Id"
|
||||
class="btn btn-sm btn-outline-secondary">View All</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<p class="text-muted small p-3 mb-0">
|
||||
<a asp-controller="AuditLog" asp-action="Index" asp-route-companyId="@Model.Id">
|
||||
View audit log for @Model.CompanyName <i class="bi bi-arrow-right ms-1"></i>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
// ── State for the refund modal ────────────────────────────────────────────────
|
||||
let _refundPaymentIntentId = null;
|
||||
let _refundMaxCents = 0;
|
||||
let _refundAmountPaid = '';
|
||||
let _refundInvoiceNumber = '';
|
||||
const _antiForgery = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
|
||||
async function loadPaymentHistory() {
|
||||
const container = document.getElementById('payment-history-container');
|
||||
container.innerHTML = '<p class="text-muted small p-3 mb-0"><span class="spinner-border spinner-border-sm me-2"></span>Loading from Stripe...</p>';
|
||||
try {
|
||||
const resp = await fetch('@Url.Action("PaymentHistory", new { id = Model.Id })');
|
||||
const data = await resp.json();
|
||||
if (data.error) {
|
||||
container.innerHTML = `<p class="text-danger small p-3 mb-0">${data.error}</p>`;
|
||||
return;
|
||||
}
|
||||
if (!data.charges || data.charges.length === 0) {
|
||||
container.innerHTML = '<p class="text-muted small p-3 mb-0">No Stripe charges found.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Date</th><th>Description</th><th>Amount</th>
|
||||
<th>Refunded</th><th>Status</th><th></th>
|
||||
</tr>
|
||||
</thead><tbody>`;
|
||||
|
||||
for (const ch of data.charges) {
|
||||
const statusBadge = ch.status === 'succeeded'
|
||||
? 'bg-success'
|
||||
: ch.status === 'pending' ? 'bg-warning text-dark' : 'bg-secondary';
|
||||
const receiptLink = ch.receipt
|
||||
? `<a href="${ch.receipt}" target="_blank" class="btn btn-outline-secondary btn-sm py-0 me-1">Receipt</a>`
|
||||
: '';
|
||||
const refundedCell = ch.amountRefunded
|
||||
? `<span class="text-danger small">${ch.amountRefunded}</span>`
|
||||
: '<span class="text-muted small">—</span>';
|
||||
const desc = ch.description ? `<span class="text-muted">${ch.description}</span>` : `<code class="small">${ch.id}</code>`;
|
||||
|
||||
// Show Refund button only for succeeded charges that still have something refundable
|
||||
const refundBtn = (ch.status === 'succeeded' && ch.refundableCents > 0)
|
||||
? `<button class="btn btn-outline-danger btn-sm py-0"
|
||||
onclick="openRefundModal('${ch.id}', ${ch.refundableCents}, '${ch.amount}', '${ch.id}')">
|
||||
Refund</button>`
|
||||
: '';
|
||||
|
||||
html += `<tr>
|
||||
<td class="small">${ch.created}</td>
|
||||
<td class="small">${desc}</td>
|
||||
<td class="small fw-semibold">${ch.amount}</td>
|
||||
<td>${refundedCell}</td>
|
||||
<td><span class="badge ${statusBadge}">${ch.status}</span></td>
|
||||
<td class="text-end">${receiptLink}${refundBtn}</td>
|
||||
</tr>`;
|
||||
}
|
||||
html += '</tbody></table></div>';
|
||||
container.innerHTML = html;
|
||||
} catch (e) {
|
||||
container.innerHTML = '<p class="text-danger small p-3 mb-0">Failed to load payment history.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function openRefundModal(chargeId, refundableCents, amountPaid, displayLabel) {
|
||||
_refundPaymentIntentId = chargeId; // reusing variable — now holds charge ID
|
||||
_refundMaxCents = refundableCents;
|
||||
_refundAmountPaid = amountPaid;
|
||||
_refundInvoiceNumber = displayLabel;
|
||||
|
||||
const maxDollars = (refundableCents / 100).toFixed(2);
|
||||
|
||||
document.getElementById('refund-invoice-number').textContent = displayLabel;
|
||||
document.getElementById('refund-amount-paid').textContent = amountPaid;
|
||||
document.getElementById('refund-max-amount').textContent = `$${maxDollars}`;
|
||||
document.getElementById('refund-amount-input').value = maxDollars;
|
||||
document.getElementById('refund-amount-input').max = maxDollars;
|
||||
document.getElementById('refund-reason-input').value = '';
|
||||
document.getElementById('refund-result').className = 'd-none';
|
||||
document.getElementById('refund-result').innerHTML = '';
|
||||
document.getElementById('refund-submit-btn').disabled = false;
|
||||
|
||||
new bootstrap.Modal(document.getElementById('refundModal')).show();
|
||||
}
|
||||
|
||||
async function submitRefund() {
|
||||
const amount = parseFloat(document.getElementById('refund-amount-input').value);
|
||||
const reason = document.getElementById('refund-reason-input').value.trim();
|
||||
const resultEl = document.getElementById('refund-result');
|
||||
const submitBtn = document.getElementById('refund-submit-btn');
|
||||
|
||||
if (!amount || amount <= 0) {
|
||||
showRefundResult('error', 'Please enter a valid refund amount.');
|
||||
return;
|
||||
}
|
||||
if (amount * 100 > _refundMaxCents + 0.5) { // +0.5 for floating-point tolerance
|
||||
showRefundResult('error', `Amount cannot exceed the refundable amount of $${(_refundMaxCents / 100).toFixed(2)}.`);
|
||||
return;
|
||||
}
|
||||
if (!reason) {
|
||||
showRefundResult('error', 'Please enter a reason for the refund.');
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Processing…';
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('chargeId', _refundPaymentIntentId);
|
||||
formData.append('amount', amount.toFixed(2));
|
||||
formData.append('reason', reason);
|
||||
formData.append('__RequestVerificationToken', _antiForgery);
|
||||
|
||||
const resp = await fetch('@Url.Action("IssueSubscriptionRefund", new { id = Model.Id })', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.error) {
|
||||
showRefundResult('error', data.error);
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<i class="bi bi-arrow-counterclockwise me-1"></i>Issue Refund';
|
||||
} else {
|
||||
showRefundResult('success',
|
||||
`Refund of ${data.amountFormatted} issued successfully. Stripe Refund ID: ${data.refundId}`);
|
||||
submitBtn.innerHTML = '<i class="bi bi-check-lg me-1"></i>Done';
|
||||
// Reload payment history to reflect updated refunded amounts
|
||||
setTimeout(() => loadPaymentHistory(), 1500);
|
||||
}
|
||||
} catch (e) {
|
||||
showRefundResult('error', 'An unexpected error occurred. Please try again.');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<i class="bi bi-arrow-counterclockwise me-1"></i>Issue Refund';
|
||||
}
|
||||
}
|
||||
|
||||
function showRefundResult(type, message) {
|
||||
const el = document.getElementById('refund-result');
|
||||
el.className = type === 'success'
|
||||
? 'alert alert-success small py-2'
|
||||
: 'alert alert-danger small py-2';
|
||||
el.innerHTML = message;
|
||||
}
|
||||
</script>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user