Consolidate company admin screens: health badge on list, tabbed detail page

Companies/Index:
- Added Health badge column (Healthy / At Risk / Critical / Never Active)
  with the numeric score in a tooltip; computed from the same signals as
  CompanyHealth/Index using the new shared CompanyHealthHelper

Companies/Details:
- Converted flat card layout to five tabs: Overview, Users, Subscription,
  Onboarding, Health; URL hash is preserved so the active tab survives
  page refresh and back navigation
- Subscription tab shows plan/status/dates with an expiry countdown and a
  "Manage Subscription & Features" button to the full Manage page
- Onboarding tab shows wizard completion, milestone progress bar, and
  first-activity dates (previously only on the standalone page)
- Health tab shows score gauge, risk badge, and individual risk signals
  with a link through to the full CompanyHealth dashboard
- JS moved to wwwroot/js/companies-details.js (avoids inline-script failures)

Infrastructure:
- Extracted ComputeHealth / ToRiskLevel / ChurnRisk to CompanyHealthHelper.cs
  (same Controllers namespace); CompanyHealthController delegates to it
- CompanyCountSummary extended with Jobs30Counts, Jobs90Counts, LastLoginDates
  (3 extra GROUP BY queries scoped to the current page IDs, not all companies)
- CompanyListDto gains HealthScore, HealthRisk, LastLoginDate

Navigation:
- Removed "Onboarding Progress" hub card from People & Activity; the data
  is now surfaced directly on the Companies/Details Onboarding tab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-12 22:22:14 -04:00
parent 786b78e502
commit 726bebdce9
10 changed files with 966 additions and 743 deletions
@@ -0,0 +1,174 @@
(function () {
'use strict';
// ── Tab persistence via URL hash ──────────────────────────────────────────
var hash = window.location.hash;
if (hash) {
var btn = document.querySelector('#companyDetailTabs button[data-bs-target="' + hash.replace('#', '#pane-') + '"]');
if (!btn) btn = document.querySelector('#companyDetailTabs button[data-bs-target="' + hash + '"]');
if (btn) bootstrap.Tab.getOrCreateInstance(btn).show();
}
document.querySelectorAll('#companyDetailTabs button[data-bs-toggle="tab"]').forEach(function (btn) {
btn.addEventListener('shown.bs.tab', function (e) {
var target = e.target.getAttribute('data-bs-target').replace('#pane-', '#');
history.replaceState(null, '', target);
});
});
// ── Reset Data modal ──────────────────────────────────────────────────────
(function () {
var input = document.getElementById('resetDataConfirmInput');
var hidden = document.getElementById('resetDataConfirmHidden');
var btn = document.getElementById('btnResetDataConfirm');
if (!input) return;
input.addEventListener('input', function () {
var ok = input.value.trim() === 'DELETE';
btn.disabled = !ok;
hidden.value = input.value.trim();
});
document.getElementById('resetDataModal').addEventListener('hidden.bs.modal', function () {
input.value = ''; hidden.value = ''; btn.disabled = true;
});
})();
// ── Hard Delete modal ─────────────────────────────────────────────────────
(function () {
var input = document.getElementById('hardDeleteConfirmInput');
var hidden = document.getElementById('hardDeleteConfirmHidden');
var btn = document.getElementById('btnHardDeleteConfirm');
if (!input) return;
input.addEventListener('input', function () {
var ok = input.value.trim() === 'DELETE';
btn.disabled = !ok;
hidden.value = input.value.trim();
});
document.getElementById('hardDeleteModal').addEventListener('hidden.bs.modal', function () {
input.value = ''; hidden.value = ''; btn.disabled = true;
});
})();
// ── Reset Password modal ──────────────────────────────────────────────────
var resetPasswordModal = document.getElementById('resetPasswordModal');
if (resetPasswordModal) {
resetPasswordModal.addEventListener('show.bs.modal', function (e) {
var btn = e.relatedTarget;
document.getElementById('resetUserId').value = btn.getAttribute('data-user-id');
document.getElementById('resetUserName').textContent = btn.getAttribute('data-user-name');
});
}
// ── User Details modal (login history) ───────────────────────────────────
var offcanvasEl = document.getElementById('userDetailOffcanvas');
if (!offcanvasEl) return;
var oc = new bootstrap.Modal(offcanvasEl);
var loading = document.getElementById('oc-loading');
var content = document.getElementById('oc-content');
var errorDiv = document.getElementById('oc-error');
var errorMsg = document.getElementById('oc-error-msg');
var actionBadgeClass = {
'Login': 'bg-success',
'Login2FABypassed': 'bg-success',
'FailedLogin': 'bg-danger',
'LoginDenied': 'bg-warning text-dark',
'AccountLockedOut': 'bg-danger',
};
var actionLabel = {
'Login': 'Login',
'Login2FABypassed': 'Login (2FA bypassed)',
'FailedLogin': 'Failed login',
'LoginDenied': 'Login denied',
'AccountLockedOut': 'Account locked out',
'SelfServiceAccountDeletion': 'Account deleted',
};
function showLoading() {
loading.classList.remove('d-none');
content.style.display = 'none';
errorDiv.style.display = 'none';
}
function showError(msg) {
loading.classList.add('d-none');
content.style.display = 'none';
errorMsg.textContent = msg;
errorDiv.style.display = '';
}
function escHtml(str) {
return String(str)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function renderUser(data) {
var u = data.user;
document.getElementById('oc-name').textContent = u.fullName;
document.getElementById('oc-fullname').textContent = u.fullName;
document.getElementById('oc-email').textContent = u.email;
var badge = document.getElementById('oc-status-badge');
badge.textContent = u.isActive ? 'Active' : 'Inactive';
badge.className = 'badge ms-auto ' + (u.isActive ? 'bg-success' : 'bg-danger');
document.getElementById('oc-role').textContent = u.companyRole ? u.companyRole.replace('Company', '') : '—';
document.getElementById('oc-dept').textContent = u.department || '—';
document.getElementById('oc-position').textContent = u.position || '—';
document.getElementById('oc-phone').textContent = u.phone || '—';
document.getElementById('oc-hire').textContent = u.hireDate || '—';
document.getElementById('oc-created').textContent = u.createdAt || '—';
document.getElementById('oc-lastlogin').textContent = u.lastLoginDate || 'Never';
document.getElementById('oc-emailconf').innerHTML = u.emailConfirmed
? '<span class="text-success"><i class="bi bi-check-circle-fill me-1"></i>Yes</span>'
: '<span class="text-warning"><i class="bi bi-x-circle-fill me-1"></i>No</span>';
var tbody = document.getElementById('oc-log-body');
var noLogs = document.getElementById('oc-no-logs');
var logWrap = document.getElementById('oc-log-table-wrap');
var countEl = document.getElementById('oc-log-count');
var logs = data.loginHistory;
tbody.innerHTML = '';
if (!logs || logs.length === 0) {
noLogs.style.display = '';
logWrap.style.display = 'none';
countEl.textContent = '';
} else {
noLogs.style.display = 'none';
logWrap.style.display = '';
countEl.textContent = '(' + logs.length + ')';
logs.forEach(function (log) {
var bc = actionBadgeClass[log.action] || 'bg-secondary';
var label = actionLabel[log.action] || log.action;
var note = log.note ? '<br><span class="text-muted">' + escHtml(log.note) + '</span>' : '';
var tr = document.createElement('tr');
tr.innerHTML =
'<td class="text-nowrap">' + escHtml(log.timestamp) + '</td>' +
'<td><span class="badge ' + bc + '">' + escHtml(label) + '</span>' + note + '</td>' +
'<td class="text-nowrap text-muted">' + escHtml(log.ipAddress) + '</td>';
tbody.appendChild(tr);
});
}
loading.classList.add('d-none');
content.style.display = '';
}
document.querySelectorAll('.user-row').forEach(function (row) {
row.addEventListener('click', function () {
showLoading();
oc.show();
fetch('/Companies/UserLoginHistory?companyId=' +
encodeURIComponent(row.dataset.companyId) +
'&userId=' + encodeURIComponent(row.dataset.userId))
.then(function (r) {
if (!r.ok) throw new Error('Server returned ' + r.status);
return r.json();
})
.then(renderUser)
.catch(function (err) { showError('Failed to load user data. ' + err.message); });
});
});
})();