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:
@@ -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>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user