PR 5: Platform Settings UI redesign

- Section headers now show a group-specific icon, colored icon tile, and a one-line description explaining what each group controls (General, Notifications, Subscriptions, Quotes, Data Retention)
- Each setting now displays UpdatedAt and UpdatedBy metadata below the current value so operators can see when and by whom a setting was last changed
- Edit modal now uses type-appropriate inputs: number (with min=0, step=1) for *Days keys, email for *Email keys, url for BaseUrl, text otherwise; each type shows a contextual hint
- Key name shown in monospace below the label on desktop for operator reference
- Added SuccessMessage TempData alert at the top of the page
- No backend or DB changes — view-only redesign

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-12 21:19:52 -04:00
parent 637be701ea
commit fb31fa7eb3
@@ -5,147 +5,241 @@
ViewData["Title"] = "Platform Settings";
ViewData["PageIcon"] = "bi-gear-wide-connected";
var groups = Model.GroupBy(s => s.GroupName ?? "General").OrderBy(g => g.Key);
static string GroupIcon(string group) => group switch
{
"Notifications" => "bi-bell",
"Subscriptions" => "bi-credit-card",
"Quotes" => "bi-file-earmark-text",
"Data Retention" => "bi-clock-history",
_ => "bi-globe"
};
static string GroupDescription(string group) => group switch
{
"Notifications" => "Controls where platform event alerts (signups, bug reports, billing events) are sent.",
"Subscriptions" => "Trial and billing defaults applied to new tenant companies at registration.",
"Quotes" => "Default validity windows and token expiry for customer-facing quote links.",
"Data Retention" => "How long audit and webhook records are kept before the nightly purge removes them.",
_ => "Core platform configuration values used across features and email links."
};
static string InputType(string key) => key switch
{
var k when k.Contains("Email", StringComparison.OrdinalIgnoreCase) => "email",
var k when k.Contains("Url", StringComparison.OrdinalIgnoreCase) => "url",
var k when k.Contains("Days", StringComparison.OrdinalIgnoreCase) => "number",
_ => "text"
};
static string InputHint(string key) => key switch
{
var k when k.Contains("Email", StringComparison.OrdinalIgnoreCase) => "e.g. admin@example.com",
var k when k.Contains("Url", StringComparison.OrdinalIgnoreCase) => "e.g. https://app.yourdomain.com",
var k when k.Contains("Days", StringComparison.OrdinalIgnoreCase) => "Enter a whole number of days",
_ => ""
};
}
@section Styles {
<style>
[data-bs-theme="dark"] a.text-dark { color: var(--bs-body-color) !important; }
[data-bs-theme="dark"] .card-header.bg-white,
[data-bs-theme="dark"] .settings-group-header { background-color: var(--bs-card-cap-bg) !important; }
.settings-group-header { background: var(--bs-body-bg); border-bottom: 1px solid var(--bs-border-color); }
.setting-meta { font-size: 0.75rem; color: var(--bs-secondary-color); margin-top: 2px; }
</style>
}
<div class="mb-4"></div>
<div class="container-fluid py-2">
@foreach (var group in groups)
{
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-sliders me-2 text-primary"></i>@group.Key
</h5>
@if (TempData["SuccessMessage"] != null)
{
<div class="alert alert-success alert-permanent alert-dismissible fade show mb-4" role="alert">
<i class="bi bi-check-circle-fill me-2"></i>@TempData["SuccessMessage"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table mb-0">
<thead>
<tr>
<th class="ps-4" style="width:30%">Setting</th>
<th style="width:45%">Value</th>
<th class="text-end pe-4" style="width:25%">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var s in group)
{
<tr>
<td class="ps-4 align-middle">
<div class="fw-semibold">@(s.Label ?? s.Key)</div>
@if (!string.IsNullOrWhiteSpace(s.Description))
{
<small class="text-muted">@s.Description</small>
}
</td>
<td class="align-middle">
@if (string.IsNullOrWhiteSpace(s.Value))
{
<span class="text-muted fst-italic">Not set</span>
}
else
{
<span>@s.Value</span>
}
</td>
<td class="text-end pe-4 align-middle">
<button class="btn btn-sm btn-outline-primary"
data-bs-toggle="modal"
data-bs-target="#editModal"
data-key="@s.Key"
data-label="@(s.Label ?? s.Key)"
data-value="@s.Value">
<i class="bi bi-pencil me-1"></i>Edit
</button>
</td>
</tr>
}
</tbody>
</table>
}
@foreach (var group in groups)
{
var groupKey = group.Key;
var icon = GroupIcon(groupKey);
var desc = GroupDescription(groupKey);
<div class="card border-0 shadow-sm mb-4">
<div class="card-header settings-group-header py-3">
<div class="d-flex align-items-start gap-3">
<div class="rounded-3 p-2 bg-primary bg-opacity-10 flex-shrink-0">
<i class="bi @icon text-primary fs-5"></i>
</div>
<div>
<h5 class="mb-0 fw-semibold">@groupKey</h5>
<p class="mb-0 small text-muted mt-1">@desc</p>
</div>
</div>
</div>
<!-- Mobile card view — shown on screens < 992px -->
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var s in group)
{
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon bg-primary"><i class="bi bi-sliders"></i></div>
<div class="mobile-card-title">
<h6>@(s.Label ?? s.Key)</h6>
@if (!string.IsNullOrWhiteSpace(s.Description))
{
<small>@s.Description</small>
}
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Value</span>
<span class="mobile-card-value">
<div class="card-body p-0">
<div class="table-responsive d-none d-lg-block">
<table class="table mb-0">
<colgroup>
<col style="width:32%">
<col style="width:43%">
<col style="width:25%">
</colgroup>
<thead class="table-light">
<tr>
<th class="ps-4 fw-semibold">Setting</th>
<th class="fw-semibold">Current Value</th>
<th class="text-end pe-4 fw-semibold">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var s in group)
{
<tr>
<td class="ps-4 align-middle">
<div class="fw-semibold">@(s.Label ?? s.Key)</div>
@if (!string.IsNullOrWhiteSpace(s.Description))
{
<small class="text-muted">@s.Description</small>
}
<div class="setting-meta font-monospace mt-1 text-muted">@s.Key</div>
</td>
<td class="align-middle">
@if (string.IsNullOrWhiteSpace(s.Value))
{
<span class="text-muted fst-italic">Not set</span>
}
else
{
@s.Value
<span class="fw-medium">@s.Value</span>
}
</span>
@if (s.UpdatedAt.HasValue)
{
<div class="setting-meta">
Updated @s.UpdatedAt.Value.ToString("MMM d, yyyy")
@if (!string.IsNullOrWhiteSpace(s.UpdatedBy))
{
<span>by @s.UpdatedBy</span>
}
</div>
}
</td>
<td class="text-end pe-4 align-middle">
<button class="btn btn-sm btn-outline-primary"
data-bs-toggle="modal"
data-bs-target="#editModal"
data-key="@s.Key"
data-label="@(s.Label ?? s.Key)"
data-value="@(s.Value ?? "")"
data-type="@InputType(s.Key)"
data-hint="@InputHint(s.Key)">
<i class="bi bi-pencil me-1"></i>Edit
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
@* Mobile card view *@
<div class="mobile-card-view d-lg-none">
<div class="mobile-card-list">
@foreach (var s in group)
{
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon bg-primary"><i class="bi @icon"></i></div>
<div class="mobile-card-title">
<h6>@(s.Label ?? s.Key)</h6>
@if (!string.IsNullOrWhiteSpace(s.Description))
{
<small>@s.Description</small>
}
</div>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Key</span>
<span class="mobile-card-value text-muted small font-monospace">@s.Key</span>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Value</span>
<span class="mobile-card-value">
@if (string.IsNullOrWhiteSpace(s.Value))
{
<span class="text-muted fst-italic">Not set</span>
}
else
{
@s.Value
}
</span>
</div>
@if (s.UpdatedAt.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Updated</span>
<span class="mobile-card-value text-muted small">
@s.UpdatedAt.Value.ToString("MMM d, yyyy")
@if (!string.IsNullOrWhiteSpace(s.UpdatedBy)) { <text>by @s.UpdatedBy</text> }
</span>
</div>
}
<div class="mobile-card-row">
<span class="mobile-card-label">Key</span>
<span class="mobile-card-value text-muted small font-monospace">@s.Key</span>
</div>
</div>
<div class="mobile-card-footer">
<button class="btn btn-sm btn-outline-primary"
data-bs-toggle="modal"
data-bs-target="#editModal"
data-key="@s.Key"
data-label="@(s.Label ?? s.Key)"
data-value="@(s.Value ?? "")"
data-type="@InputType(s.Key)"
data-hint="@InputHint(s.Key)">
<i class="bi bi-pencil me-1"></i>Edit
</button>
</div>
</div>
<div class="mobile-card-footer">
<button class="btn btn-sm btn-outline-primary"
data-bs-toggle="modal"
data-bs-target="#editModal"
data-key="@s.Key"
data-label="@(s.Label ?? s.Key)"
data-value="@s.Value">
<i class="bi bi-pencil me-1"></i>Edit
</button>
</div>
</div>
}
}
</div>
</div>
</div>
</div>
</div>
}
}
@if (!Model.Any())
{
<div class="card border-0 shadow-sm">
<div class="card-body text-center py-5">
<i class="bi bi-sliders fs-1 text-muted opacity-50"></i>
<p class="mt-3 text-muted">No platform settings found. Run a database migration to seed defaults.</p>
@if (!Model.Any())
{
<div class="card border-0 shadow-sm">
<div class="card-body text-center py-5">
<i class="bi bi-gear-wide-connected fs-1 text-muted opacity-50"></i>
<p class="mt-3 text-muted">No platform settings found. Run a database migration to seed defaults.</p>
</div>
</div>
</div>
}
}
</div>
<!-- Edit Modal -->
<div class="modal fade" id="editModal" tabindex="-1">
<div class="modal fade" id="editModal" tabindex="-1" aria-labelledby="editModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form asp-action="Save" method="post">
@Html.AntiForgeryToken()
<input type="hidden" id="editKey" name="key" />
<div class="modal-header">
<h5 class="modal-title">Edit Setting: <span id="editLabel"></span></h5>
<h5 class="modal-title" id="editModalLabel">
<i class="bi bi-pencil-square me-2 text-primary"></i>Edit: <span id="editLabel"></span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<label class="form-label fw-semibold" for="editValue">Value</label>
<input type="text" class="form-control" id="editValue" name="value" />
<input class="form-control" id="editValue" name="value" />
<div class="form-text" id="editHint"></div>
<div class="form-text text-muted mt-2">
Leave blank to clear the setting and use the built-in default.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
@@ -161,10 +255,25 @@
@section Scripts {
<script>
document.getElementById('editModal').addEventListener('show.bs.modal', function (e) {
const btn = e.relatedTarget;
document.getElementById('editKey').value = btn.dataset.key;
const btn = e.relatedTarget;
const type = btn.dataset.type || 'text';
const hint = btn.dataset.hint || '';
const input = document.getElementById('editValue');
document.getElementById('editKey').value = btn.dataset.key;
document.getElementById('editLabel').textContent = btn.dataset.label;
document.getElementById('editValue').value = btn.dataset.value ?? '';
document.getElementById('editHint').textContent = hint;
input.type = type;
input.value = btn.dataset.value ?? '';
if (type === 'number') {
input.min = '0';
input.step = '1';
} else {
input.removeAttribute('min');
input.removeAttribute('step');
}
});
</script>
}