825 lines
45 KiB
Text
825 lines
45 KiB
Text
@* Views/Admin/Users/Index.cshtml *@
|
|
@model IEnumerable<RegisterViewModel>
|
|
@using Web.Authorization
|
|
@using Web.ViewModel.AccountVM
|
|
|
|
@{
|
|
ViewData["Title"] = "User Management";
|
|
}
|
|
|
|
@section Styles {
|
|
<style>
|
|
@@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Space+Grotesk:wght@300;400;500;600;700&display=swap');
|
|
|
|
:root {
|
|
--nex-emerald: #34d399;
|
|
--nex-green: #10b981;
|
|
--nex-teal: #14b8a6;
|
|
--nex-lime: #a3e635;
|
|
--nex-blue: #60a5fa;
|
|
--nex-purple: #c084fc;
|
|
--nex-red: #f87171;
|
|
--nex-amber: #fbbf24;
|
|
--nex-cyan: #22d3ee;
|
|
--dark-950: #020617;
|
|
--dark-900: #0f172a;
|
|
--dark-800: #1e293b;
|
|
--dark-700: #334155;
|
|
--dark-600: #475569;
|
|
--dark-500: #64748b;
|
|
--dark-400: #94a3b8;
|
|
--dark-300: #cbd5e1;
|
|
--dark-200: #e2e8f0;
|
|
--glass-bg: rgba(255,255,255,0.04);
|
|
--glass-border: rgba(255,255,255,0.08);
|
|
--font-main: 'Space Grotesk', sans-serif;
|
|
--font-mono: 'JetBrains Mono', monospace;
|
|
}
|
|
|
|
* { margin:0; padding:0; box-sizing:border-box; }
|
|
body { font-family: var(--font-main); background: var(--dark-900); color: #e2e8f0; overflow-x: hidden; }
|
|
|
|
.nex-bg { position:fixed; inset:0; z-index:-1; overflow:hidden; }
|
|
.nex-bg .grid { position:absolute; inset:0; background-image: linear-gradient(rgba(52,211,153,0.04) 1px, transparent 1px), linear-gradient(90deg, rgba(52,211,153,0.04) 1px, transparent 1px); background-size:60px 60px; }
|
|
.nex-bg .mesh { position:absolute; inset:0; background: radial-gradient(circle at 15% 25%, rgba(52,211,153,0.08) 0%, transparent 50%), radial-gradient(circle at 85% 55%, rgba(96,165,250,0.06) 0%, transparent 50%), radial-gradient(circle at 50% 85%, rgba(192,132,252,0.05) 0%, transparent 50%); }
|
|
|
|
.page-header { position:relative; z-index:10; padding:2.5rem 0 1.5rem; }
|
|
.container { max-width:1400px; margin:0 auto; padding:0 2rem; }
|
|
|
|
.breadcrumb-nex { display:flex; align-items:center; gap:0.75rem; font-family:var(--font-mono); font-size:0.72rem; text-transform:uppercase; letter-spacing:0.06em; margin-bottom:1.5rem; flex-wrap:wrap; }
|
|
.breadcrumb-nex a { color:var(--dark-400); text-decoration:none; transition:color .2s; }
|
|
.breadcrumb-nex a:hover { color:var(--nex-emerald); }
|
|
.breadcrumb-nex .sep { color:var(--dark-600); font-size:0.55rem; }
|
|
.breadcrumb-nex .current { color:var(--nex-emerald); }
|
|
|
|
.header-row { display:flex; align-items:flex-start; justify-content:space-between; gap:2rem; flex-wrap:wrap; }
|
|
.header-badge { display:inline-flex; align-items:center; gap:0.5rem; padding:0.4rem 1rem; background:rgba(52,211,153,0.1); border:1px solid rgba(52,211,153,0.25); border-radius:50px; font-family:var(--font-mono); font-size:0.65rem; font-weight:600; color:var(--nex-emerald); letter-spacing:0.1em; margin-bottom:1rem; }
|
|
.header-badge .dot { width:6px; height:6px; border-radius:50%; background:var(--nex-emerald); animation:pulse-dot 2s ease infinite; }
|
|
@@keyframes pulse-dot { 0%,100%{opacity:1} 50%{opacity:.4} }
|
|
|
|
.header-title { font-size:2.2rem; font-weight:700; color:#f8fafc; line-height:1.2; margin-bottom:0.5rem; }
|
|
.header-title .grad { background:linear-gradient(135deg, var(--nex-emerald), var(--nex-teal)); -webkit-background-clip:text; -webkit-text-fill-color:transparent; }
|
|
.header-sub { color:var(--dark-400); font-size:0.9rem; }
|
|
.header-actions { display:flex; gap:0.75rem; flex-wrap:wrap; align-items:flex-start; padding-top:0.5rem; }
|
|
|
|
.h-btn { display:inline-flex; align-items:center; gap:0.5rem; padding:0.6rem 1.3rem; border-radius:10px; font-family:var(--font-main); font-size:0.82rem; font-weight:600; border:none; cursor:pointer; transition:all .25s ease; text-decoration:none; }
|
|
.h-btn.pri { background:linear-gradient(135deg, var(--nex-emerald), var(--nex-teal)); color:#fff; }
|
|
.h-btn.pri:hover { transform:translateY(-2px); box-shadow:0 8px 25px rgba(52,211,153,0.3); }
|
|
.h-btn.sec { background:var(--glass-bg); border:1px solid var(--glass-border); color:#e2e8f0; }
|
|
.h-btn.sec:hover { background:rgba(52,211,153,0.1); border-color:rgba(52,211,153,0.3); color:var(--nex-emerald); }
|
|
.h-btn.danger { background:rgba(248,113,113,0.15); border:1px solid rgba(248,113,113,0.3); color:var(--nex-red); }
|
|
.h-btn.danger:hover { background:rgba(248,113,113,0.25); transform:translateY(-2px); }
|
|
.h-btn:disabled { opacity:0.5; cursor:not-allowed; transform:none !important; }
|
|
|
|
.stats-row { display:grid; grid-template-columns:repeat(4, 1fr); gap:1rem; margin-bottom:2rem; }
|
|
.stat-chip { background:var(--glass-bg); border:1px solid var(--glass-border); border-radius:14px; padding:1.2rem 1.5rem; display:flex; align-items:center; gap:1rem; transition:all .3s ease; }
|
|
.stat-chip:hover { border-color:rgba(52,211,153,0.2); transform:translateY(-2px); }
|
|
.stat-chip .s-icon { width:44px; height:44px; border-radius:12px; display:flex; align-items:center; justify-content:center; font-size:1.1rem; flex-shrink:0; }
|
|
.stat-chip .s-icon.green { background:rgba(52,211,153,0.15); color:var(--nex-emerald); }
|
|
.stat-chip .s-icon.blue { background:rgba(96,165,250,0.15); color:var(--nex-blue); }
|
|
.stat-chip .s-icon.purple { background:rgba(192,132,252,0.15); color:var(--nex-purple); }
|
|
.stat-chip .s-icon.amber { background:rgba(251,191,36,0.15); color:var(--nex-amber); }
|
|
.stat-chip .s-val { font-size:1.6rem; font-weight:700; color:#f8fafc; line-height:1; }
|
|
.stat-chip .s-lbl { font-size:0.72rem; color:var(--dark-400); text-transform:uppercase; letter-spacing:0.05em; font-family:var(--font-mono); margin-top:2px; }
|
|
|
|
.nex-card { background:var(--glass-bg); border:1px solid var(--glass-border); border-radius:16px; backdrop-filter:blur(12px); position:relative; overflow:hidden; }
|
|
.nex-card .top-glow { position:absolute; top:0; left:0; right:0; height:2px; background:linear-gradient(90deg, transparent, var(--nex-emerald), transparent); opacity:0.6; }
|
|
|
|
.tbl-head { display:flex; align-items:center; justify-content:space-between; padding:1.25rem 1.5rem; border-bottom:1px solid var(--glass-border); flex-wrap:wrap; gap:1rem; }
|
|
.tbl-title { display:flex; align-items:center; gap:0.75rem; font-size:1rem; font-weight:600; color:#f8fafc; }
|
|
.tbl-title i { color:var(--nex-emerald); }
|
|
.tbl-count { font-family:var(--font-mono); font-size:0.7rem; color:var(--dark-400); background:rgba(52,211,153,0.1); padding:0.25rem 0.65rem; border-radius:6px; border:1px solid rgba(52,211,153,0.2); }
|
|
|
|
.search-box { display:flex; align-items:center; gap:0.5rem; background:rgba(255,255,255,0.05); border:1px solid var(--glass-border); border-radius:10px; padding:0 1rem; transition:border-color .2s; }
|
|
.search-box:focus-within { border-color:rgba(52,211,153,0.4); }
|
|
.search-box i { color:var(--dark-500); font-size:0.85rem; }
|
|
.search-box input { background:none; border:none; outline:none; color:#e2e8f0; font-family:var(--font-main); font-size:0.82rem; padding:0.55rem 0; width:180px; }
|
|
.search-box input::placeholder { color:var(--dark-500); }
|
|
|
|
.user-table { width:100%; border-collapse:collapse; }
|
|
.user-table thead th { padding:0.85rem 1.5rem; text-align:left; font-family:var(--font-mono); font-size:0.68rem; font-weight:600; color:var(--dark-400); text-transform:uppercase; letter-spacing:0.08em; border-bottom:1px solid var(--glass-border); background:rgba(255,255,255,0.02); }
|
|
.user-table thead th:first-child { width:50px; text-align:center; }
|
|
.user-table thead th:last-child { text-align:right; }
|
|
.user-table tbody tr { border-bottom:1px solid rgba(255,255,255,0.04); transition:background .2s; }
|
|
.user-table tbody tr:hover { background:rgba(52,211,153,0.04); }
|
|
.user-table tbody tr:last-child { border-bottom:none; }
|
|
.user-table tbody td { padding:0.9rem 1.5rem; font-size:0.88rem; vertical-align:middle; }
|
|
.user-table tbody td:first-child { text-align:center; }
|
|
.user-table tbody td:last-child { text-align:right; }
|
|
|
|
.nex-check { width:18px; height:18px; accent-color:var(--nex-emerald); cursor:pointer; }
|
|
|
|
.user-cell { display:flex; align-items:center; gap:0.85rem; }
|
|
.user-avatar { width:38px; height:38px; border-radius:10px; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:0.8rem; color:#fff; flex-shrink:0; text-transform:uppercase; }
|
|
.user-name { font-weight:600; color:#f1f5f9; line-height:1.3; }
|
|
.user-name small { display:block; font-weight:400; font-size:0.75rem; color:var(--dark-400); margin-top:1px; }
|
|
|
|
.role-badge { display:inline-flex; align-items:center; gap:0.3rem; padding:0.25rem 0.7rem; border-radius:6px; font-size:0.72rem; font-weight:600; font-family:var(--font-mono); margin-right:0.3rem; }
|
|
.role-badge.admin { background:rgba(248,113,113,0.15); color:var(--nex-red); border:1px solid rgba(248,113,113,0.25); }
|
|
.role-badge.user { background:rgba(96,165,250,0.15); color:var(--nex-blue); border:1px solid rgba(96,165,250,0.25); }
|
|
.role-badge.manager { background:rgba(251,191,36,0.15); color:var(--nex-amber); border:1px solid rgba(251,191,36,0.25); }
|
|
.role-badge.default { background:rgba(148,163,184,0.15); color:var(--dark-300); border:1px solid rgba(148,163,184,0.2); }
|
|
|
|
.act-btn { display:inline-flex; align-items:center; justify-content:center; width:34px; height:34px; border-radius:8px; border:1px solid var(--glass-border); background:var(--glass-bg); color:var(--dark-300); cursor:pointer; transition:all .2s; font-size:0.82rem; margin-left:0.35rem; }
|
|
.act-btn:hover { transform:translateY(-1px); }
|
|
.act-btn.edit:hover { background:rgba(96,165,250,0.15); border-color:rgba(96,165,250,0.3); color:var(--nex-blue); }
|
|
.act-btn.del:hover { background:rgba(248,113,113,0.15); border-color:rgba(248,113,113,0.3); color:var(--nex-red); }
|
|
|
|
.tbl-foot { display:flex; align-items:center; justify-content:space-between; padding:1rem 1.5rem; border-top:1px solid var(--glass-border); flex-wrap:wrap; gap:0.75rem; }
|
|
.selected-info { font-family:var(--font-mono); font-size:0.72rem; color:var(--dark-400); }
|
|
.selected-info span { color:var(--nex-emerald); font-weight:600; }
|
|
|
|
/* Modal */
|
|
.modal-overlay { display:none; position:fixed; inset:0; z-index:9999; background:rgba(2,6,23,0.8); backdrop-filter:blur(8px); align-items:center; justify-content:center; padding:2rem; }
|
|
.modal-overlay.active { display:flex; animation:fadeIn .25s ease; }
|
|
@@keyframes fadeIn { from{opacity:0} to{opacity:1} }
|
|
|
|
.modal-box { background:var(--dark-800); border:1px solid var(--glass-border); border-radius:20px; width:100%; max-width:520px; max-height:90vh; overflow-y:auto; position:relative; animation:slideUp .3s ease; }
|
|
@@keyframes slideUp { from{opacity:0;transform:translateY(20px)} to{opacity:1;transform:translateY(0)} }
|
|
|
|
.modal-box .m-glow { position:absolute; top:0; left:0; right:0; height:3px; border-radius:20px 20px 0 0; }
|
|
.modal-box .m-glow.green { background:linear-gradient(90deg, var(--nex-emerald), var(--nex-teal)); }
|
|
.modal-box .m-glow.blue { background:linear-gradient(90deg, var(--nex-blue), var(--nex-cyan)); }
|
|
.modal-box .m-glow.red { background:linear-gradient(90deg, var(--nex-red), #dc2626); }
|
|
|
|
.m-header { display:flex; align-items:center; justify-content:space-between; padding:1.5rem 1.75rem 1rem; }
|
|
.m-header-left { display:flex; align-items:center; gap:0.85rem; }
|
|
.m-header-icon { width:42px; height:42px; border-radius:12px; display:flex; align-items:center; justify-content:center; font-size:1.1rem; }
|
|
.m-header-icon.green { background:rgba(52,211,153,0.15); color:var(--nex-emerald); }
|
|
.m-header-icon.blue { background:rgba(96,165,250,0.15); color:var(--nex-blue); }
|
|
.m-header-icon.red { background:rgba(248,113,113,0.15); color:var(--nex-red); }
|
|
.m-header h3 { font-size:1.1rem; font-weight:700; color:#f8fafc; }
|
|
.m-header p { font-size:0.78rem; color:var(--dark-400); margin-top:2px; }
|
|
.m-close { width:36px; height:36px; border-radius:10px; border:1px solid var(--glass-border); background:none; color:var(--dark-400); display:flex; align-items:center; justify-content:center; cursor:pointer; transition:all .2s; font-size:0.9rem; }
|
|
.m-close:hover { background:rgba(248,113,113,0.15); border-color:rgba(248,113,113,0.3); color:var(--nex-red); }
|
|
|
|
.m-body { padding:0.5rem 1.75rem 1.5rem; }
|
|
|
|
.f-group { margin-bottom:1.1rem; }
|
|
.f-label { display:block; font-family:var(--font-mono); font-size:0.7rem; font-weight:600; color:var(--dark-300); text-transform:uppercase; letter-spacing:0.06em; margin-bottom:0.45rem; }
|
|
.f-input { width:100%; padding:0.65rem 1rem; background:rgba(255,255,255,0.05); border:1px solid var(--glass-border); border-radius:10px; color:#e2e8f0; font-family:var(--font-main); font-size:0.88rem; outline:none; transition:border-color .2s, box-shadow .2s; }
|
|
.f-input:focus { border-color:rgba(52,211,153,0.5); box-shadow:0 0 0 3px rgba(52,211,153,0.1); }
|
|
.f-input::placeholder { color:var(--dark-500); }
|
|
.f-input.error { border-color:rgba(248,113,113,0.5); }
|
|
.f-row { display:grid; grid-template-columns:1fr 1fr; gap:1rem; }
|
|
|
|
.role-chips { display:flex; flex-wrap:wrap; gap:0.5rem; margin-top:0.3rem; }
|
|
.role-chip { position:relative; }
|
|
.role-chip input { position:absolute; opacity:0; pointer-events:none; }
|
|
.role-chip label { display:inline-flex; align-items:center; gap:0.4rem; padding:0.45rem 0.9rem; border-radius:8px; border:1px solid var(--glass-border); background:var(--glass-bg); color:var(--dark-300); font-size:0.78rem; font-weight:600; cursor:pointer; transition:all .2s; }
|
|
.role-chip input:checked + label { background:rgba(52,211,153,0.15); border-color:rgba(52,211,153,0.4); color:var(--nex-emerald); }
|
|
.role-chip label:hover { border-color:rgba(52,211,153,0.3); }
|
|
|
|
.pw-bar { height:3px; border-radius:2px; background:var(--dark-700); margin-top:0.4rem; overflow:hidden; }
|
|
.pw-bar .fill { height:100%; border-radius:2px; transition:width .3s, background .3s; width:0; }
|
|
|
|
.m-footer { display:flex; align-items:center; justify-content:flex-end; gap:0.75rem; padding:1rem 1.75rem 1.5rem; border-top:1px solid var(--glass-border); }
|
|
|
|
/* Error alert in modal */
|
|
.modal-errors { margin-bottom:1rem; padding:0.75rem 1rem; background:rgba(248,113,113,0.1); border:1px solid rgba(248,113,113,0.25); border-radius:10px; }
|
|
.modal-errors ul { list-style:none; padding:0; margin:0; }
|
|
.modal-errors li { font-size:0.8rem; color:var(--nex-red); padding:0.15rem 0; display:flex; align-items:center; gap:0.4rem; }
|
|
.modal-errors li i { font-size:0.7rem; }
|
|
|
|
/* Success toast */
|
|
.nex-toast { position:fixed; top:1.5rem; right:1.5rem; z-index:99999; padding:0.85rem 1.5rem; border-radius:12px; font-size:0.85rem; font-weight:600; display:flex; align-items:center; gap:0.6rem; animation:slideIn .3s ease, fadeOut .3s ease 2.7s forwards; pointer-events:none; }
|
|
.nex-toast.success { background:rgba(52,211,153,0.15); border:1px solid rgba(52,211,153,0.3); color:var(--nex-emerald); backdrop-filter:blur(12px); }
|
|
.nex-toast.error { background:rgba(248,113,113,0.15); border:1px solid rgba(248,113,113,0.3); color:var(--nex-red); backdrop-filter:blur(12px); }
|
|
@@keyframes slideIn { from{opacity:0;transform:translateX(30px)} to{opacity:1;transform:translateX(0)} }
|
|
@@keyframes fadeOut { to{opacity:0;transform:translateY(-10px)} }
|
|
|
|
/* Delete modal */
|
|
.del-user-info { display:flex; align-items:center; gap:1rem; padding:1rem 1.25rem; background:rgba(248,113,113,0.08); border:1px solid rgba(248,113,113,0.15); border-radius:12px; margin-bottom:1rem; }
|
|
.del-avatar { width:48px; height:48px; border-radius:12px; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:1rem; color:#fff; flex-shrink:0; }
|
|
.del-details h4 { font-size:0.95rem; font-weight:600; color:#f8fafc; }
|
|
.del-details p { font-size:0.8rem; color:var(--dark-400); margin-top:2px; }
|
|
.del-warning { font-size:0.82rem; color:var(--dark-300); line-height:1.6; }
|
|
.del-warning strong { color:var(--nex-red); }
|
|
|
|
.empty-state { text-align:center; padding:4rem 2rem; }
|
|
.empty-state i { font-size:3rem; color:var(--dark-600); margin-bottom:1rem; }
|
|
.empty-state h4 { color:var(--dark-300); font-size:1.1rem; margin-bottom:0.5rem; }
|
|
.empty-state p { color:var(--dark-500); font-size:0.85rem; }
|
|
|
|
/* Spinner */
|
|
.btn-spinner { display:none; width:16px; height:16px; border:2px solid rgba(255,255,255,0.3); border-top-color:#fff; border-radius:50%; animation:spin .6s linear infinite; }
|
|
@@keyframes spin { to{transform:rotate(360deg)} }
|
|
|
|
@@media(max-width:1200px) { .stats-row { grid-template-columns:repeat(2, 1fr); } }
|
|
@@media(max-width:768px) {
|
|
.header-row { flex-direction:column; }
|
|
.stats-row { grid-template-columns:1fr; }
|
|
.f-row { grid-template-columns:1fr; }
|
|
.tbl-head { flex-direction:column; align-items:flex-start; }
|
|
.user-table { display:block; overflow-x:auto; }
|
|
.header-title { font-size:1.6rem; }
|
|
}
|
|
</style>
|
|
}
|
|
|
|
<div class="nex-bg"><div class="grid"></div><div class="mesh"></div></div>
|
|
|
|
<!-- Header -->
|
|
<section class="page-header">
|
|
<div class="container">
|
|
<div class="breadcrumb-nex">
|
|
<a href="@Url.Action("Index", "Dashboard")"><i class="fa-solid fa-gauge-high"></i> Admin</a>
|
|
<i class="fa-solid fa-chevron-right sep"></i>
|
|
<span class="current">User Management</span>
|
|
</div>
|
|
<div class="header-row">
|
|
<div>
|
|
<div class="header-badge"><span class="dot"></span> IDENTITY & ACCESS CONTROL</div>
|
|
<h1 class="header-title">User <span class="grad">Management</span></h1>
|
|
<p class="header-sub">Manage system users, roles, and access permissions</p>
|
|
</div>
|
|
<div class="header-actions">
|
|
<button type="button" class="h-btn pri" onclick="if(checkPermission(userPermissions.canCreate)) openCreateModal()">
|
|
<i class="fa-solid fa-user-plus"></i> New User
|
|
</button>
|
|
<button type="button" class="h-btn danger d-none" id="bulkDeleteBtn" onclick="if(checkPermission(userPermissions.canDelete)) confirmBulkDelete()">
|
|
<i class="fa-solid fa-trash-can"></i> Delete Selected
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Stats -->
|
|
<section style="position:relative;z-index:10;padding:0 0 1.5rem">
|
|
<div class="container">
|
|
@{
|
|
var totalUsers = Model.Count();
|
|
var adminCount = Model.Count(u => u.SelectedRoles != null && u.SelectedRoles.Any(r => r.Equals("Admin", StringComparison.OrdinalIgnoreCase)));
|
|
var userCount = Model.Count(u => u.SelectedRoles != null && u.SelectedRoles.Any(r => r.Equals("User", StringComparison.OrdinalIgnoreCase)));
|
|
var roleCount = Model.SelectMany(u => u.SelectedRoles ?? new List<string>()).Distinct().Count();
|
|
}
|
|
<div class="stats-row">
|
|
<div class="stat-chip">
|
|
<div class="s-icon green"><i class="fa-solid fa-users"></i></div>
|
|
<div><div class="s-val">@totalUsers</div><div class="s-lbl">Total Users</div></div>
|
|
</div>
|
|
<div class="stat-chip">
|
|
<div class="s-icon blue"><i class="fa-solid fa-user-shield"></i></div>
|
|
<div><div class="s-val">@adminCount</div><div class="s-lbl">Admins</div></div>
|
|
</div>
|
|
<div class="stat-chip">
|
|
<div class="s-icon purple"><i class="fa-solid fa-user"></i></div>
|
|
<div><div class="s-val">@userCount</div><div class="s-lbl">Users</div></div>
|
|
</div>
|
|
<div class="stat-chip">
|
|
<div class="s-icon amber"><i class="fa-solid fa-key"></i></div>
|
|
<div><div class="s-val">@roleCount</div><div class="s-lbl">Roles</div></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Users Table -->
|
|
<section style="position:relative;z-index:10;padding-bottom:3rem;">
|
|
<div class="container">
|
|
<partial name="_Notification" />
|
|
|
|
<form asp-action="DeleteSelected" method="post" id="deleteForm">
|
|
@Html.AntiForgeryToken()
|
|
<div class="nex-card">
|
|
<div class="top-glow"></div>
|
|
|
|
<div class="tbl-head">
|
|
<div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap;">
|
|
<div class="tbl-title"><i class="fa-solid fa-users-gear"></i> System Users</div>
|
|
<div class="tbl-count">@totalUsers registered</div>
|
|
</div>
|
|
<div class="search-box">
|
|
<i class="fa-solid fa-magnifying-glass"></i>
|
|
<input type="text" id="searchInput" placeholder="Search users..." oninput="filterTable()" />
|
|
</div>
|
|
</div>
|
|
|
|
@if (Model.Any())
|
|
{
|
|
<table class="user-table" id="userTable">
|
|
<thead>
|
|
<tr>
|
|
<th><input type="checkbox" class="nex-check" id="selectAll" onclick="toggleAll(this.checked)" /></th>
|
|
<th>User</th>
|
|
<th>Email</th>
|
|
<th>Roles</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@{ var colors = new[] { "#6366f1","#ec4899","#f59e0b","#10b981","#3b82f6","#8b5cf6","#ef4444","#06b6d4" }; var idx = 0; }
|
|
@foreach (var user in Model)
|
|
{
|
|
var initials = ((user.FirstName?.Length > 0 ? user.FirstName[0].ToString() : "") + (user.LastName?.Length > 0 ? user.LastName[0].ToString() : "")).ToUpper();
|
|
var bgColor = colors[idx % colors.Length];
|
|
var rolesStr = user.SelectedRoles != null ? string.Join(",", user.SelectedRoles) : "";
|
|
idx++;
|
|
<tr data-name="@($"{user.FirstName} {user.LastName}".ToLower())" data-email="@user.Email?.ToLower()">
|
|
<td><input type="checkbox" class="nex-check" name="selectedUserIds" value="@user.Email" onchange="updateSelectionCount()" /></td>
|
|
<td>
|
|
<div class="user-cell">
|
|
<div class="user-avatar" style="background:@bgColor">@initials</div>
|
|
<div class="user-name">
|
|
@user.FirstName @user.LastName
|
|
<small>ID: @(user.Id?.Length > 8 ? user.Id[..8] + "…" : user.Id)</small>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td style="color:var(--dark-300)">@user.Email</td>
|
|
<td>
|
|
@if (user.SelectedRoles != null)
|
|
{
|
|
@foreach (var role in user.SelectedRoles)
|
|
{
|
|
var roleClass = role.ToLower() switch { "admin" => "admin", "user" => "user", "manager" => "manager", _ => "default" };
|
|
<span class="role-badge @roleClass"><i class="fa-solid fa-circle" style="font-size:0.35rem"></i> @role</span>
|
|
}
|
|
}
|
|
</td>
|
|
<td>
|
|
<button type="button" class="act-btn edit" title="Edit user"
|
|
onclick="if(checkPermission(userPermissions.canEdit)) openEditModal('@user.Id', '@Html.Raw(user.FirstName?.Replace("'", "\\'"))', '@Html.Raw(user.LastName?.Replace("'", "\\'"))', '@rolesStr')">
|
|
<i class="fa-solid fa-pen-to-square"></i>
|
|
</button>
|
|
<button type="button" class="act-btn del" title="Delete user"
|
|
onclick="if(checkPermission(userPermissions.canDelete)) openDeleteModal('@user.Email', '@Html.Raw((user.FirstName + " " + user.LastName).Replace("'", "\\'"))', '@user.Email')">
|
|
<i class="fa-solid fa-trash-can"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
}
|
|
else
|
|
{
|
|
<div class="empty-state">
|
|
<i class="fa-solid fa-user-slash"></i>
|
|
<h4>No users found</h4>
|
|
<p>Create your first user to get started</p>
|
|
</div>
|
|
}
|
|
|
|
<div class="tbl-foot">
|
|
<div class="selected-info"><span id="selCount">0</span> of @totalUsers selected</div>
|
|
<div style="font-family:var(--font-mono);font-size:0.7rem;color:var(--dark-500);">Last updated: @DateTime.Now.ToString("MMM dd, yyyy HH:mm")</div>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ═══════════ CREATE MODAL ═══════════ -->
|
|
<div class="modal-overlay" id="createModal">
|
|
<div class="modal-box">
|
|
<div class="m-glow green"></div>
|
|
<div class="m-header">
|
|
<div class="m-header-left">
|
|
<div class="m-header-icon green"><i class="fa-solid fa-user-plus"></i></div>
|
|
<div><h3>Create New User</h3><p>Add a new member to the system</p></div>
|
|
</div>
|
|
<button type="button" class="m-close" onclick="closeModal('createModal')"><i class="fa-solid fa-xmark"></i></button>
|
|
</div>
|
|
<form id="createForm" novalidate>
|
|
@Html.AntiForgeryToken()
|
|
<div class="m-body">
|
|
<div id="createErrors" class="modal-errors" style="display:none"><ul></ul></div>
|
|
<div class="f-row">
|
|
<div class="f-group">
|
|
<label class="f-label"><i class="fa-solid fa-user"></i> First Name *</label>
|
|
<input type="text" name="FirstName" class="f-input" placeholder="Enter first name" required />
|
|
</div>
|
|
<div class="f-group">
|
|
<label class="f-label"><i class="fa-solid fa-user"></i> Last Name *</label>
|
|
<input type="text" name="LastName" class="f-input" placeholder="Enter last name" required />
|
|
</div>
|
|
</div>
|
|
<div class="f-group">
|
|
<label class="f-label"><i class="fa-solid fa-envelope"></i> Email Address *</label>
|
|
<input type="email" name="Email" class="f-input" placeholder="user@@example.com" required />
|
|
</div>
|
|
<div class="f-group">
|
|
<label class="f-label"><i class="fa-solid fa-lock"></i> Password *</label>
|
|
<input type="password" name="Password" class="f-input" placeholder="Min. 6 characters" required minlength="6" oninput="checkPwStrength(this)" />
|
|
<div class="pw-bar"><div class="fill" id="pwStrength"></div></div>
|
|
</div>
|
|
<div class="f-group">
|
|
<label class="f-label"><i class="fa-solid fa-lock"></i> Confirm Password *</label>
|
|
<input type="password" name="ConfirmPassword" class="f-input" placeholder="Re-enter password" required />
|
|
</div>
|
|
<div class="f-group">
|
|
<label class="f-label"><i class="fa-solid fa-shield-halved"></i> Assign Roles *</label>
|
|
<div class="role-chips">
|
|
@if (ViewBag.Roles != null)
|
|
{
|
|
@foreach (var role in (List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.Roles)
|
|
{
|
|
<div class="role-chip">
|
|
<input type="checkbox" name="SelectedRoles" value="@role.Value" id="create_role_@role.Value" />
|
|
<label for="create_role_@role.Value"><i class="fa-solid fa-shield-halved"></i> @role.Text</label>
|
|
</div>
|
|
}
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="m-footer">
|
|
<button type="button" class="h-btn sec" onclick="closeModal('createModal')"><i class="fa-solid fa-xmark"></i> Cancel</button>
|
|
<button type="submit" class="h-btn pri" id="createSubmitBtn">
|
|
<div class="btn-spinner" id="createSpinner"></div>
|
|
<i class="fa-solid fa-user-plus" id="createIcon"></i> Create User
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ═══════════ EDIT MODAL ═══════════ -->
|
|
<div class="modal-overlay" id="editModal">
|
|
<div class="modal-box">
|
|
<div class="m-glow blue"></div>
|
|
<div class="m-header">
|
|
<div class="m-header-left">
|
|
<div class="m-header-icon blue"><i class="fa-solid fa-user-pen"></i></div>
|
|
<div><h3>Edit User</h3><p>Update user details and roles</p></div>
|
|
</div>
|
|
<button type="button" class="m-close" onclick="closeModal('editModal')"><i class="fa-solid fa-xmark"></i></button>
|
|
</div>
|
|
<form id="editForm" novalidate>
|
|
@Html.AntiForgeryToken()
|
|
<input type="hidden" name="Id" id="editId" />
|
|
<div class="m-body">
|
|
<div id="editErrors" class="modal-errors" style="display:none"><ul></ul></div>
|
|
<div class="f-row">
|
|
<div class="f-group">
|
|
<label class="f-label"><i class="fa-solid fa-user"></i> First Name *</label>
|
|
<input type="text" name="FirstName" id="editFirstName" class="f-input" required />
|
|
</div>
|
|
<div class="f-group">
|
|
<label class="f-label"><i class="fa-solid fa-user"></i> Last Name *</label>
|
|
<input type="text" name="LastName" id="editLastName" class="f-input" required />
|
|
</div>
|
|
</div>
|
|
<div class="f-group">
|
|
<label class="f-label"><i class="fa-solid fa-shield-halved"></i> Assign Roles *</label>
|
|
<div class="role-chips" id="editRoleChips">
|
|
@if (ViewBag.Roles != null)
|
|
{
|
|
@foreach (var role in (List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.Roles)
|
|
{
|
|
<div class="role-chip">
|
|
<input type="checkbox" name="SelectedRoles" value="@role.Value" id="edit_role_@role.Value" />
|
|
<label for="edit_role_@role.Value"><i class="fa-solid fa-shield-halved"></i> @role.Text</label>
|
|
</div>
|
|
}
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="m-footer">
|
|
<button type="button" class="h-btn sec" onclick="closeModal('editModal')"><i class="fa-solid fa-xmark"></i> Cancel</button>
|
|
<button type="submit" class="h-btn pri" id="editSubmitBtn" style="background:linear-gradient(135deg, var(--nex-blue), var(--nex-cyan))">
|
|
<div class="btn-spinner" id="editSpinner"></div>
|
|
<i class="fa-solid fa-floppy-disk" id="editIcon"></i> Save Changes
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ═══════════ DELETE MODAL ═══════════ -->
|
|
<div class="modal-overlay" id="deleteModal">
|
|
<div class="modal-box" style="max-width:460px">
|
|
<div class="m-glow red"></div>
|
|
<div class="m-header">
|
|
<div class="m-header-left">
|
|
<div class="m-header-icon red"><i class="fa-solid fa-triangle-exclamation"></i></div>
|
|
<div><h3>Delete User</h3><p>This action cannot be undone</p></div>
|
|
</div>
|
|
<button type="button" class="m-close" onclick="closeModal('deleteModal')"><i class="fa-solid fa-xmark"></i></button>
|
|
</div>
|
|
<div class="m-body">
|
|
<div class="del-user-info">
|
|
<div class="del-avatar" id="delAvatar" style="background:#6366f1">??</div>
|
|
<div class="del-details">
|
|
<h4 id="delName">User Name</h4>
|
|
<p id="delEmail">user@example.com</p>
|
|
</div>
|
|
</div>
|
|
<p class="del-warning">
|
|
<i class="fa-solid fa-circle-exclamation"></i> You are about to <strong>permanently delete</strong> this user account.
|
|
All associated data, roles, and permissions will be removed. This action is <strong>irreversible</strong>.
|
|
</p>
|
|
</div>
|
|
<div class="m-footer">
|
|
<button type="button" class="h-btn sec" onclick="closeModal('deleteModal')"><i class="fa-solid fa-xmark"></i> Cancel</button>
|
|
<form id="singleDeleteForm" asp-action="DeleteSelected" method="post" style="display:inline">
|
|
@Html.AntiForgeryToken()
|
|
<input type="hidden" name="selectedUserIds" id="delUserId" />
|
|
<button type="submit" class="h-btn danger"><i class="fa-solid fa-trash-can"></i> Delete User</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ═══════════ BULK DELETE MODAL ═══════════ -->
|
|
<div class="modal-overlay" id="bulkDeleteModal">
|
|
<div class="modal-box" style="max-width:460px">
|
|
<div class="m-glow red"></div>
|
|
<div class="m-header">
|
|
<div class="m-header-left">
|
|
<div class="m-header-icon red"><i class="fa-solid fa-users-slash"></i></div>
|
|
<div><h3>Delete Selected Users</h3><p>Bulk removal confirmation</p></div>
|
|
</div>
|
|
<button type="button" class="m-close" onclick="closeModal('bulkDeleteModal')"><i class="fa-solid fa-xmark"></i></button>
|
|
</div>
|
|
<div class="m-body">
|
|
<p class="del-warning">
|
|
<i class="fa-solid fa-circle-exclamation"></i> You are about to <strong>permanently delete <span id="bulkCount">0</span> users</strong>
|
|
from the system. All their data, roles, and permissions will be removed. This is <strong>irreversible</strong>.
|
|
</p>
|
|
</div>
|
|
<div class="m-footer">
|
|
<button type="button" class="h-btn sec" onclick="closeModal('bulkDeleteModal')"><i class="fa-solid fa-xmark"></i> Cancel</button>
|
|
<button type="button" class="h-btn danger" onclick="submitBulkDelete()"><i class="fa-solid fa-trash-can"></i> Delete All Selected</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
@section Scripts {
|
|
|
|
<script>
|
|
const userPermissions = {
|
|
canCreate: @User.HasPermission(Permissions.Users.Create).ToString().ToLower(),
|
|
canEdit: @User.HasPermission(Permissions.Users.Edit).ToString().ToLower(),
|
|
canDelete: @User.HasPermission(Permissions.Users.Delete).ToString().ToLower()
|
|
};
|
|
const accessDeniedUrl = '@Url.Action("Index", "AccessDenied", new { area = "Admin" })';
|
|
|
|
function checkPermission(permission) {
|
|
if (!permission) {
|
|
window.location.href = accessDeniedUrl;
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
</script>
|
|
<script>
|
|
// ── Toast ──
|
|
function showToast(message, type) {
|
|
const toast = document.createElement('div');
|
|
toast.className = 'nex-toast ' + type;
|
|
const icon = type === 'success' ? 'fa-circle-check' : 'fa-circle-xmark';
|
|
toast.innerHTML = '<i class="fa-solid ' + icon + '"></i> ' + message;
|
|
document.body.appendChild(toast);
|
|
setTimeout(() => toast.remove(), 3200);
|
|
}
|
|
|
|
// ── Selection ──
|
|
function toggleAll(checked) {
|
|
document.querySelectorAll('input[name="selectedUserIds"]').forEach(cb => cb.checked = checked);
|
|
updateSelectionCount();
|
|
}
|
|
|
|
function updateSelectionCount() {
|
|
const checked = document.querySelectorAll('input[name="selectedUserIds"]:checked').length;
|
|
document.getElementById('selCount').textContent = checked;
|
|
const bulkBtn = document.getElementById('bulkDeleteBtn');
|
|
if (checked > 0) bulkBtn.classList.remove('d-none');
|
|
else bulkBtn.classList.add('d-none');
|
|
|
|
const total = document.querySelectorAll('input[name="selectedUserIds"]').length;
|
|
const selectAll = document.getElementById('selectAll');
|
|
if (selectAll) {
|
|
selectAll.indeterminate = checked > 0 && checked < total;
|
|
selectAll.checked = checked === total && total > 0;
|
|
}
|
|
}
|
|
|
|
// ── Search ──
|
|
function filterTable() {
|
|
const q = document.getElementById('searchInput').value.toLowerCase();
|
|
document.querySelectorAll('#userTable tbody tr').forEach(row => {
|
|
const name = row.dataset.name || '';
|
|
const email = row.dataset.email || '';
|
|
row.style.display = (name.includes(q) || email.includes(q)) ? '' : 'none';
|
|
});
|
|
}
|
|
|
|
// ── Modal Helpers ──
|
|
function openModal(id) {
|
|
document.getElementById(id).classList.add('active');
|
|
document.body.style.overflow = 'hidden';
|
|
}
|
|
function closeModal(id) {
|
|
document.getElementById(id).classList.remove('active');
|
|
document.body.style.overflow = '';
|
|
}
|
|
|
|
document.querySelectorAll('.modal-overlay').forEach(overlay => {
|
|
overlay.addEventListener('click', e => { if (e.target === overlay) closeModal(overlay.id); });
|
|
});
|
|
document.addEventListener('keydown', e => {
|
|
if (e.key === 'Escape') document.querySelectorAll('.modal-overlay.active').forEach(m => closeModal(m.id));
|
|
});
|
|
|
|
function showModalErrors(containerId, errors) {
|
|
const container = document.getElementById(containerId);
|
|
const ul = container.querySelector('ul');
|
|
ul.innerHTML = '';
|
|
errors.forEach(err => {
|
|
const li = document.createElement('li');
|
|
li.innerHTML = '<i class="fa-solid fa-circle-xmark"></i> ' + err;
|
|
ul.appendChild(li);
|
|
});
|
|
container.style.display = 'block';
|
|
}
|
|
|
|
function hideModalErrors(containerId) {
|
|
document.getElementById(containerId).style.display = 'none';
|
|
}
|
|
|
|
function setButtonLoading(btnId, spinnerId, iconId, loading) {
|
|
const btn = document.getElementById(btnId);
|
|
const spinner = document.getElementById(spinnerId);
|
|
const icon = document.getElementById(iconId);
|
|
if (loading) {
|
|
btn.disabled = true;
|
|
spinner.style.display = 'block';
|
|
if (icon) icon.style.display = 'none';
|
|
} else {
|
|
btn.disabled = false;
|
|
spinner.style.display = 'none';
|
|
if (icon) icon.style.display = '';
|
|
}
|
|
}
|
|
|
|
// ── CREATE via AJAX ──
|
|
function openCreateModal() {
|
|
document.getElementById('createForm').reset();
|
|
hideModalErrors('createErrors');
|
|
openModal('createModal');
|
|
}
|
|
|
|
document.getElementById('createForm').addEventListener('submit', function(e) {
|
|
e.preventDefault();
|
|
hideModalErrors('createErrors');
|
|
|
|
// Client-side validation
|
|
const form = this;
|
|
const firstName = form.querySelector('[name="FirstName"]').value.trim();
|
|
const lastName = form.querySelector('[name="LastName"]').value.trim();
|
|
const email = form.querySelector('[name="Email"]').value.trim();
|
|
const password = form.querySelector('[name="Password"]').value;
|
|
const confirmPassword = form.querySelector('[name="ConfirmPassword"]').value;
|
|
const roles = form.querySelectorAll('[name="SelectedRoles"]:checked');
|
|
|
|
const errors = [];
|
|
if (!firstName) errors.push('First Name is required.');
|
|
if (!lastName) errors.push('Last Name is required.');
|
|
if (!email) errors.push('Email is required.');
|
|
if (!password || password.length < 6) errors.push('Password must be at least 6 characters.');
|
|
if (password !== confirmPassword) errors.push('Passwords do not match.');
|
|
if (roles.length === 0) errors.push('You must select at least one role.');
|
|
|
|
if (errors.length > 0) {
|
|
showModalErrors('createErrors', errors);
|
|
return;
|
|
}
|
|
|
|
setButtonLoading('createSubmitBtn', 'createSpinner', 'createIcon', true);
|
|
|
|
const formData = new FormData(form);
|
|
|
|
fetch('@Url.Action("RegisterAjax")', {
|
|
method: 'POST',
|
|
body: formData
|
|
})
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
setButtonLoading('createSubmitBtn', 'createSpinner', 'createIcon', false);
|
|
if (data.success) {
|
|
closeModal('createModal');
|
|
showToast(data.message || 'User created successfully!', 'success');
|
|
setTimeout(() => location.reload(), 800);
|
|
} else {
|
|
showModalErrors('createErrors', data.errors || ['An unknown error occurred.']);
|
|
}
|
|
})
|
|
.catch(err => {
|
|
setButtonLoading('createSubmitBtn', 'createSpinner', 'createIcon', false);
|
|
showModalErrors('createErrors', ['Network error. Please try again.']);
|
|
});
|
|
});
|
|
|
|
// ── EDIT via AJAX ──
|
|
function openEditModal(id, firstName, lastName, roles) {
|
|
document.getElementById('editForm').reset();
|
|
hideModalErrors('editErrors');
|
|
|
|
document.getElementById('editId').value = id;
|
|
document.getElementById('editFirstName').value = firstName;
|
|
document.getElementById('editLastName').value = lastName;
|
|
|
|
// Reset all role checkboxes
|
|
document.querySelectorAll('#editRoleChips input').forEach(cb => cb.checked = false);
|
|
|
|
// Check matching roles
|
|
if (roles) {
|
|
roles.split(',').forEach(r => {
|
|
const cb = document.getElementById('edit_role_' + r.trim());
|
|
if (cb) cb.checked = true;
|
|
});
|
|
}
|
|
|
|
openModal('editModal');
|
|
}
|
|
|
|
document.getElementById('editForm').addEventListener('submit', function(e) {
|
|
e.preventDefault();
|
|
hideModalErrors('editErrors');
|
|
|
|
const form = this;
|
|
const firstName = form.querySelector('[name="FirstName"]').value.trim();
|
|
const lastName = form.querySelector('[name="LastName"]').value.trim();
|
|
const roles = form.querySelectorAll('[name="SelectedRoles"]:checked');
|
|
|
|
const errors = [];
|
|
if (!firstName) errors.push('First Name is required.');
|
|
if (!lastName) errors.push('Last Name is required.');
|
|
if (roles.length === 0) errors.push('You must select at least one role.');
|
|
|
|
if (errors.length > 0) {
|
|
showModalErrors('editErrors', errors);
|
|
return;
|
|
}
|
|
|
|
setButtonLoading('editSubmitBtn', 'editSpinner', 'editIcon', true);
|
|
|
|
const formData = new FormData(form);
|
|
|
|
fetch('@Url.Action("EditAjax")', {
|
|
method: 'POST',
|
|
body: formData
|
|
})
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
setButtonLoading('editSubmitBtn', 'editSpinner', 'editIcon', false);
|
|
if (data.success) {
|
|
closeModal('editModal');
|
|
showToast(data.message || 'User updated successfully!', 'success');
|
|
setTimeout(() => location.reload(), 800);
|
|
} else {
|
|
showModalErrors('editErrors', data.errors || ['An unknown error occurred.']);
|
|
}
|
|
})
|
|
.catch(err => {
|
|
setButtonLoading('editSubmitBtn', 'editSpinner', 'editIcon', false);
|
|
showModalErrors('editErrors', ['Network error. Please try again.']);
|
|
});
|
|
});
|
|
|
|
// ── DELETE ──
|
|
function openDeleteModal(email, name, displayEmail) {
|
|
document.getElementById('delUserId').value = email;
|
|
document.getElementById('delName').textContent = name;
|
|
document.getElementById('delEmail').textContent = displayEmail;
|
|
|
|
const initials = name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
|
|
document.getElementById('delAvatar').textContent = initials;
|
|
|
|
openModal('deleteModal');
|
|
}
|
|
|
|
// ── BULK DELETE ──
|
|
function confirmBulkDelete() {
|
|
const count = document.querySelectorAll('input[name="selectedUserIds"]:checked').length;
|
|
if (count === 0) return;
|
|
document.getElementById('bulkCount').textContent = count;
|
|
openModal('bulkDeleteModal');
|
|
}
|
|
|
|
function submitBulkDelete() {
|
|
closeModal('bulkDeleteModal');
|
|
document.getElementById('deleteForm').submit();
|
|
}
|
|
|
|
// ── Password Strength ──
|
|
function checkPwStrength(input) {
|
|
const val = input.value;
|
|
const bar = document.getElementById('pwStrength');
|
|
let score = 0;
|
|
if (val.length >= 6) score++;
|
|
if (val.length >= 10) score++;
|
|
if (/[A-Z]/.test(val)) score++;
|
|
if (/[0-9]/.test(val)) score++;
|
|
if (/[^A-Za-z0-9]/.test(val)) score++;
|
|
|
|
const pct = (score / 5) * 100;
|
|
bar.style.width = pct + '%';
|
|
bar.style.background = pct <= 40 ? 'var(--nex-red)' : pct <= 70 ? 'var(--nex-amber)' : 'var(--nex-emerald)';
|
|
}
|
|
</script>
|
|
}
|