Add SVP org hierarchy discovery to BU Lookup panel
Backend: - GET /api/ivanti/findings/org-hierarchy — returns all SVP names and all BU names from Ivanti suggest API (Admin only) - GET /api/ivanti/findings/bus-by-svp?svp=<name> — returns BUs under a specific SVP with finding counts (Admin only) Frontend (Admin > BU Lookup tab): - 'Load Org Hierarchy' button fetches all SVPs and BUs - SVP dropdown (sorted by finding count) to filter BUs by org leader - Results table shows BU name, finding count, and configured status (green badge for BUs already in the dashboard, grey for unconfigured) - Enables admin to discover which BUs roll up under an SVP for correct team assignment when onboarding new users
This commit is contained in:
@@ -996,6 +996,53 @@ function BULookupPanel() {
|
||||
const [results, setResults] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [orgData, setOrgData] = useState(null);
|
||||
const [orgLoading, setOrgLoading] = useState(false);
|
||||
const [selectedSvp, setSelectedSvp] = useState('');
|
||||
const [svpBus, setSvpBus] = useState(null);
|
||||
const [svpBusLoading, setSvpBusLoading] = useState(false);
|
||||
|
||||
const CONFIGURED_BUS = ['NTS-AEO-STEAM', 'NTS-AEO-ACCESS-ENG', 'NTS-AEO-ACCESS-OPS', 'NTS-AEO-INTELDEV'];
|
||||
|
||||
const handleLoadOrgHierarchy = async () => {
|
||||
setOrgLoading(true);
|
||||
setOrgData(null);
|
||||
setSelectedSvp('');
|
||||
setSvpBus(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/ivanti/findings/org-hierarchy`, { credentials: 'include' });
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
setOrgData(data);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setOrgLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSvpSelect = async (svp) => {
|
||||
setSelectedSvp(svp);
|
||||
setSvpBus(null);
|
||||
if (!svp) return;
|
||||
setSvpBusLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/ivanti/findings/bus-by-svp?svp=${encodeURIComponent(svp)}`, { credentials: 'include' });
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
setSvpBus(data);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSvpBusLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = async () => {
|
||||
const q = query.trim();
|
||||
@@ -1106,6 +1153,112 @@ function BULookupPanel() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ----------------------------------------------------------------- */}
|
||||
{/* Org Discovery — SVP-based BU discovery */}
|
||||
{/* ----------------------------------------------------------------- */}
|
||||
<div style={{ marginTop: '2rem', borderTop: '1px solid rgba(14,165,233,0.15)', paddingTop: '1.5rem' }}>
|
||||
<h3 style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: 700, color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '0.5rem' }}>
|
||||
Org Discovery
|
||||
</h3>
|
||||
<p style={{ fontSize: '0.8rem', color: '#94A3B8', marginBottom: '1rem' }}>
|
||||
Load SVP and BU data from the Ivanti suggest API to discover which teams exist under each SVP. Configured BUs are highlighted in green.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '1rem' }}>
|
||||
<button
|
||||
onClick={handleLoadOrgHierarchy}
|
||||
disabled={orgLoading}
|
||||
style={{
|
||||
padding: '0.5rem 1rem', borderRadius: '0.375rem',
|
||||
background: 'rgba(14, 165, 233, 0.15)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.4)',
|
||||
color: '#0EA5E9', fontSize: '0.8rem', fontWeight: 600,
|
||||
fontFamily: 'monospace', cursor: 'pointer',
|
||||
opacity: orgLoading ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{orgLoading ? 'Loading...' : 'Load Org Hierarchy'}
|
||||
</button>
|
||||
{orgData && (
|
||||
<span style={{ fontSize: '0.75rem', color: '#64748B' }}>
|
||||
{orgData.svps.length} SVPs, {orgData.bus.length} BUs loaded
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{orgData && orgData.svps.length > 0 && (
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<select
|
||||
value={selectedSvp}
|
||||
onChange={e => handleSvpSelect(e.target.value)}
|
||||
style={{
|
||||
width: '100%', padding: '0.5rem 0.75rem',
|
||||
background: 'rgba(15, 23, 42, 0.6)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.25)',
|
||||
borderRadius: '0.375rem', color: '#E2E8F0',
|
||||
fontSize: '0.85rem', fontFamily: 'monospace', outline: 'none',
|
||||
}}
|
||||
>
|
||||
<option value="">— Select an SVP —</option>
|
||||
{orgData.svps
|
||||
.sort((a, b) => (b.count || 0) - (a.count || 0))
|
||||
.map((s, i) => (
|
||||
<option key={i} value={s.key}>{s.key} ({(s.count || 0).toLocaleString()} findings)</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{svpBusLoading && (
|
||||
<div style={{ padding: '0.75rem', color: '#94A3B8', fontSize: '0.8rem' }}>Loading BUs for selected SVP...</div>
|
||||
)}
|
||||
|
||||
{svpBus && !svpBusLoading && (
|
||||
<div>
|
||||
<div style={{ fontSize: '0.75rem', color: '#64748B', marginBottom: '0.75rem' }}>
|
||||
{svpBus.bus.length} BU(s) under {svpBus.svp}:
|
||||
</div>
|
||||
{svpBus.bus.length === 0 ? (
|
||||
<div style={{ padding: '1rem', textAlign: 'center', color: '#475569', fontSize: '0.85rem' }}>
|
||||
No BUs found for this SVP.
|
||||
</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid rgba(14,165,233,0.2)' }}>
|
||||
<th style={{ textAlign: 'left', padding: '0.5rem', color: '#0EA5E9', fontWeight: 600 }}>BU Name</th>
|
||||
<th style={{ textAlign: 'left', padding: '0.5rem', color: '#0EA5E9', fontWeight: 600 }}>Finding Count</th>
|
||||
<th style={{ textAlign: 'left', padding: '0.5rem', color: '#0EA5E9', fontWeight: 600 }}>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{svpBus.bus
|
||||
.sort((a, b) => (b.count || 0) - (a.count || 0))
|
||||
.map((bu, i) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid rgba(255,255,255,0.03)' }}>
|
||||
<td style={{ padding: '0.5rem', color: '#CBD5E1', fontFamily: 'monospace' }}>{bu.key}</td>
|
||||
<td style={{ padding: '0.5rem', color: '#CBD5E1', fontFamily: 'monospace' }}>{(bu.count || 0).toLocaleString()}</td>
|
||||
<td style={{ padding: '0.5rem' }}>
|
||||
<span style={{
|
||||
padding: '0.15rem 0.5rem', borderRadius: '0.25rem', fontSize: '0.7rem', fontWeight: 600, fontFamily: 'monospace',
|
||||
background: CONFIGURED_BUS.includes(bu.key) ? 'rgba(34,197,94,0.15)' : 'rgba(100,116,139,0.15)',
|
||||
border: CONFIGURED_BUS.includes(bu.key) ? '1px solid rgba(34,197,94,0.3)' : '1px solid rgba(100,116,139,0.3)',
|
||||
color: CONFIGURED_BUS.includes(bu.key) ? '#4ADE80' : '#94A3B8',
|
||||
}}>
|
||||
{CONFIGURED_BUS.includes(bu.key) ? 'Configured' : 'Not Configured'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user